detached fdef8ac

core: drop empty session files instead of writing meta-only stubs

every zot launch was creating a session file with just a meta line,
even when the user exited without prompting. /sessions and ls -la
ended up showing dozens of empty entries.

now Session tracks messagesAppended and freshFile (true for
NewSession, false for OpenSession). Close() removes the file when
both conditions hold: this process created it AND no messages
were ever appended. resumed sessions are never auto-deleted even
if the resume run added nothing, since the prior content is real.

PruneEmptySessions sweeps existing meta-only stubs from the cwd's
session dir on each interactive launch. cheap (only reads enough
of each file to find a 'message' line) and fixes the existing
backlog automatically the first time you reopen zot in a project.
commit
fdef8ac614c87822cb7fa75dd75c5316fe245a7d
author
patriceckhart · · 2026-04-19 09:38 UTC
parent
0a69274
signature
unsigned
refs
tag: v0.0.11
2 files · +88 -11
M internal/agent/cli.go +4 -0
@@ -265,6 +265,10 @@ func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string)
265265 if args.NoSess {
266266 return nil, nil
267267 }
268+ // Sweep meta-only files left over from older zot versions (and from
269+ // any session that crashed before its first AppendMessage). Cheap;
270+ // reads the first few bytes of each file in the cwd's session dir.
271+ core.PruneEmptySessions(ZotHome(), args.CWD)
268272 var (
269273 s *core.Session
270274 msgs []provider.Message
@@ -265,6 +265,10 @@ func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string)
265 if args.NoSess {265 if args.NoSess {
266 return nil, nil266 return nil, nil
267 }267 }
268+ // Sweep meta-only files left over from older zot versions (and from
269+ // any session that crashed before its first AppendMessage). Cheap;
270+ // reads the first few bytes of each file in the cwd's session dir.
271+ core.PruneEmptySessions(ZotHome(), args.CWD)
268 var (272 var (
269 s *core.Session273 s *core.Session
270 msgs []provider.Message274 msgs []provider.Message
M internal/core/session.go +84 -11
@@ -23,6 +23,18 @@ type Session struct {
2323 Meta SessionMeta
2424 writer *os.File
2525 buf *bufio.Writer
26+
27+ // freshFile is true when the file was created by NewSession (this
28+ // process owns it) and false when OpenSession reopened an existing
29+ // transcript. Used by Close() to delete the file if the run never
30+ // appended any messages — prevents a flood of empty session files
31+ // from sessions the user opens then exits without prompting.
32+ freshFile bool
33+
34+ // messagesAppended counts AppendMessage calls. Combined with
35+ // freshFile it tells Close() whether the session left any content
36+ // worth keeping.
37+ messagesAppended int
2638 }
2739
2840 // SessionMeta is written as the first line of every session file.
@@ -72,11 +84,12 @@ func NewSession(root, cwd, providerName, model, version string) (*Session, error
7284 return nil, err
7385 }
7486 s := &Session{
75- ID: id,
76- Path: p,
77- Meta: SessionMeta{ID: id, CWD: cwd, Provider: providerName, Model: model, Started: time.Now().UTC(), Version: version},
78- writer: f,
79- buf: bufio.NewWriter(f),
87+ ID: id,
88+ Path: p,
89+ Meta: SessionMeta{ID: id, CWD: cwd, Provider: providerName, Model: model, Started: time.Now().UTC(), Version: version},
90+ writer: f,
91+ buf: bufio.NewWriter(f),
92+ freshFile: true,
8093 }
8194 if err := s.writeLine(sessionLine{Type: "meta", Meta: &s.Meta}); err != nil {
8295 f.Close()
@@ -223,6 +236,51 @@ func firstUserText(line []byte) string {
223236 return ""
224237 }
225238
239+// PruneEmptySessions deletes session files in cwd's session directory
240+// that contain only a meta line (no messages were ever appended).
241+// Cleans up the backlog of empty stubs created by old zot versions
242+// that wrote a meta line at NewSession time and never followed up.
243+// Errors are swallowed; the caller treats this as best-effort.
244+func PruneEmptySessions(root, cwd string) {
245+ dir := SessionsDir(root, cwd)
246+ entries, err := os.ReadDir(dir)
247+ if err != nil {
248+ return
249+ }
250+ for _, e := range entries {
251+ if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {
252+ continue
253+ }
254+ p := filepath.Join(dir, e.Name())
255+ if sessionHasNoMessages(p) {
256+ _ = os.Remove(p)
257+ }
258+ }
259+}
260+
261+// sessionHasNoMessages returns true when the file at path contains
262+// no lines of type "message". Meta-only / usage-only files count as
263+// empty. Used by PruneEmptySessions and the Describe path.
264+func sessionHasNoMessages(path string) bool {
265+ f, err := os.Open(path)
266+ if err != nil {
267+ return false
268+ }
269+ defer f.Close()
270+ sc := bufio.NewScanner(f)
271+ sc.Buffer(make([]byte, 0, 64*1024), 20*1024*1024)
272+ for sc.Scan() {
273+ var head sessionLineHead
274+ if err := json.Unmarshal(sc.Bytes(), &head); err != nil {
275+ continue
276+ }
277+ if head.Type == "message" {
278+ return false
279+ }
280+ }
281+ return true
282+}
283+
226284 // ListSessions returns session file paths for cwd, newest first.
227285 func ListSessions(root, cwd string) []string {
228286 dir := SessionsDir(root, cwd)
@@ -245,7 +303,11 @@ func (s *Session) AppendMessage(m provider.Message) error {
245303 if s == nil {
246304 return nil
247305 }
248- return s.writeLine(sessionLine{Type: "message", Message: &m})
306+ if err := s.writeLine(sessionLine{Type: "message", Message: &m}); err != nil {
307+ return err
308+ }
309+ s.messagesAppended++
310+ return nil
249311 }
250312
251313 // UpdateModel records a provider/model switch in the session file.
@@ -268,16 +330,27 @@ func (s *Session) AppendUsage(u, cum provider.Usage) error {
268330 return s.writeLine(sessionLine{Type: "usage", Usage: &u, Cumulative: &cum})
269331 }
270332
271-// Close flushes and closes the session file.
333+// Close flushes and closes the session file. If the session was
334+// freshly created in this process and never had any messages
335+// appended (the user opened zot, looked around, and exited without
336+// prompting), the file is deleted on close so the sessions list
337+// doesn't fill up with empty meta-only stubs.
272338 func (s *Session) Close() error {
273339 if s == nil {
274340 return nil
275341 }
276- if err := s.buf.Flush(); err != nil {
277- s.writer.Close()
278- return err
342+ flushErr := s.buf.Flush()
343+ closeErr := s.writer.Close()
344+ if s.freshFile && s.messagesAppended == 0 {
345+ // Best-effort cleanup. We deliberately don't propagate the
346+ // remove error: if it fails (file already gone, perms changed)
347+ // the worst case is one stale empty file in the listing.
348+ _ = os.Remove(s.Path)
349+ }
350+ if flushErr != nil {
351+ return flushErr
279352 }
280- return s.writer.Close()
353+ return closeErr
281354 }
282355
283356 func (s *Session) writeLine(row sessionLine) error {
@@ -23,6 +23,18 @@ type Session struct {
23 Meta SessionMeta23 Meta SessionMeta
24 writer *os.File24 writer *os.File
25 buf *bufio.Writer25 buf *bufio.Writer
26+
27+ // freshFile is true when the file was created by NewSession (this
28+ // process owns it) and false when OpenSession reopened an existing
29+ // transcript. Used by Close() to delete the file if the run never
30+ // appended any messages — prevents a flood of empty session files
31+ // from sessions the user opens then exits without prompting.
32+ freshFile bool
33+
34+ // messagesAppended counts AppendMessage calls. Combined with
35+ // freshFile it tells Close() whether the session left any content
36+ // worth keeping.
37+ messagesAppended int
26 }38 }
27 39
28 // SessionMeta is written as the first line of every session file.40 // SessionMeta is written as the first line of every session file.
@@ -72,11 +84,12 @@ func NewSession(root, cwd, providerName, model, version string) (*Session, error
72 return nil, err84 return nil, err
73 }85 }
74 s := &Session{86 s := &Session{
75- ID: id,87+ ID: id,
76- Path: p,88+ Path: p,
77- Meta: SessionMeta{ID: id, CWD: cwd, Provider: providerName, Model: model, Started: time.Now().UTC(), Version: version},89+ Meta: SessionMeta{ID: id, CWD: cwd, Provider: providerName, Model: model, Started: time.Now().UTC(), Version: version},
78- writer: f,90+ writer: f,
79- buf: bufio.NewWriter(f),91+ buf: bufio.NewWriter(f),
92+ freshFile: true,
80 }93 }
81 if err := s.writeLine(sessionLine{Type: "meta", Meta: &s.Meta}); err != nil {94 if err := s.writeLine(sessionLine{Type: "meta", Meta: &s.Meta}); err != nil {
82 f.Close()95 f.Close()
@@ -223,6 +236,51 @@ func firstUserText(line []byte) string {
223 return ""236 return ""
224 }237 }
225 238
239+// PruneEmptySessions deletes session files in cwd's session directory
240+// that contain only a meta line (no messages were ever appended).
241+// Cleans up the backlog of empty stubs created by old zot versions
242+// that wrote a meta line at NewSession time and never followed up.
243+// Errors are swallowed; the caller treats this as best-effort.
244+func PruneEmptySessions(root, cwd string) {
245+ dir := SessionsDir(root, cwd)
246+ entries, err := os.ReadDir(dir)
247+ if err != nil {
248+ return
249+ }
250+ for _, e := range entries {
251+ if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {
252+ continue
253+ }
254+ p := filepath.Join(dir, e.Name())
255+ if sessionHasNoMessages(p) {
256+ _ = os.Remove(p)
257+ }
258+ }
259+}
260+
261+// sessionHasNoMessages returns true when the file at path contains
262+// no lines of type "message". Meta-only / usage-only files count as
263+// empty. Used by PruneEmptySessions and the Describe path.
264+func sessionHasNoMessages(path string) bool {
265+ f, err := os.Open(path)
266+ if err != nil {
267+ return false
268+ }
269+ defer f.Close()
270+ sc := bufio.NewScanner(f)
271+ sc.Buffer(make([]byte, 0, 64*1024), 20*1024*1024)
272+ for sc.Scan() {
273+ var head sessionLineHead
274+ if err := json.Unmarshal(sc.Bytes(), &head); err != nil {
275+ continue
276+ }
277+ if head.Type == "message" {
278+ return false
279+ }
280+ }
281+ return true
282+}
283+
226 // ListSessions returns session file paths for cwd, newest first.284 // ListSessions returns session file paths for cwd, newest first.
227 func ListSessions(root, cwd string) []string {285 func ListSessions(root, cwd string) []string {
228 dir := SessionsDir(root, cwd)286 dir := SessionsDir(root, cwd)
@@ -245,7 +303,11 @@ func (s *Session) AppendMessage(m provider.Message) error {
245 if s == nil {303 if s == nil {
246 return nil304 return nil
247 }305 }
248- return s.writeLine(sessionLine{Type: "message", Message: &m})306+ if err := s.writeLine(sessionLine{Type: "message", Message: &m}); err != nil {
307+ return err
308+ }
309+ s.messagesAppended++
310+ return nil
249 }311 }
250 312
251 // UpdateModel records a provider/model switch in the session file.313 // UpdateModel records a provider/model switch in the session file.
@@ -268,16 +330,27 @@ func (s *Session) AppendUsage(u, cum provider.Usage) error {
268 return s.writeLine(sessionLine{Type: "usage", Usage: &u, Cumulative: &cum})330 return s.writeLine(sessionLine{Type: "usage", Usage: &u, Cumulative: &cum})
269 }331 }
270 332
271-// Close flushes and closes the session file.333+// Close flushes and closes the session file. If the session was
334+// freshly created in this process and never had any messages
335+// appended (the user opened zot, looked around, and exited without
336+// prompting), the file is deleted on close so the sessions list
337+// doesn't fill up with empty meta-only stubs.
272 func (s *Session) Close() error {338 func (s *Session) Close() error {
273 if s == nil {339 if s == nil {
274 return nil340 return nil
275 }341 }
276- if err := s.buf.Flush(); err != nil {342+ flushErr := s.buf.Flush()
277- s.writer.Close()343+ closeErr := s.writer.Close()
278- return err344+ if s.freshFile && s.messagesAppended == 0 {
345+ // Best-effort cleanup. We deliberately don't propagate the
346+ // remove error: if it fails (file already gone, perms changed)
347+ // the worst case is one stale empty file in the listing.
348+ _ = os.Remove(s.Path)
349+ }
350+ if flushErr != nil {
351+ return flushErr
279 }352 }
280- return s.writer.Close()353+ return closeErr
281 }354 }
282 355
283 func (s *Session) writeLine(row sessionLine) error {356 func (s *Session) writeLine(row sessionLine) error {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close