Problem
Running several agent sessions in parallel (Claude Code, Codex, ...) is now a normal workflow, but there is no way to see which sessions are active right now and what each one is doing. ccsession already scans every transcript on disk; it only lacks an activity-oriented view.
This issue adds ccsession top: a live, auto-refreshing picker showing recently-active sessions, with the preview pane following the highlighted session's tail. Enter resumes the session, exactly like the default picker.
UX spec
ccsession top # live view, default window 15m, refresh every 2s
ccsession top --window 1h # show sessions active within the last hour
ccsession top --interval 5s # refresh cadence
Row format (TSV, same column conventions as ccsession list):
STATUS SOURCE LAST DIR LABEL
RUNNING claude 12s ago ccsession fix preview highlight ...
IDLE codex 4m ago myapp add login endpoint ...
RUNNING (green): activity within the last 60 seconds.
IDLE (dim): activity within --window but older than 60 seconds.
- Sessions with no activity inside
--window are not listed.
- The list reloads automatically every
--interval without the user pressing anything; the highlighted line and query are preserved across reloads (fzf reload keeps them).
- Preview pane: same
ccsession preview output, with --preview-window set to follow so a streaming session visibly scrolls.
- Enter: resume the highlighted session (same
resume path as the default picker).
- The three matcher-mode keybindings from the default picker are NOT needed here (fuzzy only). Grep/dir modes are out of scope.
Implementation plan
1. Activity timestamp on session.Session
- Add
FileMTime time.Time to session.Session (internal/session/types.go).
- Populate it in
ParseSessionTail (internal/session/parse.go): readTail already calls f.Stat() (parse.go:170) — capture fi.ModTime() there and thread it back, do not add a second os.Stat.
- Backends that are not one-file-per-session (opencode) leave it zero. Define the effective activity time as:
func (s *Session) ActiveAt() time.Time {
if !s.FileMTime.IsZero() { return s.FileMTime }
return s.LastTime
}
FileMTime is preferred over LastTime because the JSONL is appended to continuously while an agent streams, so mtime moves even when the parsed tail timestamp lags.
2. list --active <duration> filter and status column
- Add to
list.Options (internal/list/list.go): Active time.Duration (zero = disabled, current behavior unchanged).
- When
Active > 0:
- drop sessions with
now.Sub(s.ActiveAt()) > opts.Active;
- prepend a status column to
formatLine output: RUNNING if now.Sub(ActiveAt()) < 60*time.Second, else IDLE. Color: green for RUNNING, faint for IDLE, following the existing color conventions in internal/ansi.
- Wire a
--active <duration> flag into cmdList in cmd/ccsession/main.go (parse with time.ParseDuration).
- Note the column indices shift when the status column is present;
top passes its own fzf --with-nth/--nth, so the default picker script is untouched.
3. top subcommand
- New case
"top" in the subcommand switch in cmd/ccsession/main.go (main.go:184).
- New file
cmd/ccsession/top.go (or extend main.go following existing style) that:
- Generates a random API key (crypto/rand, hex) and a temp file path for the port handshake (use
os.CreateTemp).
- Launches fzf via
exec.Command with:
FZF_API_KEY=<key> and FZF_DEFAULT_COMMAND unset; input provided by --bind 'start:reload(<CCSESSION_BIN> list --active <window> --color=always)';
--listen (no port argument — fzf picks a free one);
--bind 'start:+execute-silent(echo $FZF_PORT > <portfile>)' — fzf exports FZF_PORT to processes it spawns, this is the documented way to discover the port from outside;
--preview '<CCSESSION_BIN> preview {N}' with --preview-window including follow;
--bind 'enter:become(<CCSESSION_BIN> resume {N})' mirroring how the default script resumes;
- reuse the
CCSESSION_BIN self-invocation pattern from defaultScriptTmpl; CCSESSION_SOURCE inherits via os.Environ() like today, so top works with any backend including all.
- In a goroutine, polls the port file until non-empty (with a ~5s timeout), then every
--interval sends POST http://127.0.0.1:<port>/ with header x-api-key: <key> and body reload(<CCSESSION_BIN> list --active <window> --color=always).
- Stops the ticker and cleans up the temp file when the fzf process exits.
top returns fzf's exit status like the default picker does (130 = cancel is not an error).
- fzf
--listen requires fzf >= 0.42 and FZF_PORT/api-key semantics stabilized well before 0.58, which the README already requires — no new version constraint.
4. Help text
- Add
top to the usage text in cmd/ccsession/main.go and to the README feature list + usage section, including the --window/--interval flags.
Edge cases
- No active sessions: fzf shows an empty list; the reload loop keeps running so sessions appear as they become active. Do not exit.
--interval < 1s: reject with a clear error (avoid hammering scans).
- Port file never appears (very old fzf, listen failure): kill fzf, print an actionable error mentioning the fzf version requirement.
resume for opencode/grok/codex works through the existing ResumeSpec path; nothing top-specific.
- NO_COLOR / non-tty behavior must match the existing picker (colors are forced with
--color=always only inside the fzf pipeline).
Testing
- Unit: status classification (
RUNNING/IDLE/excluded) as a table-driven test over synthetic ActiveAt values; --active filtering in internal/list with fake sessions.
- Unit:
ParseSessionTail populates FileMTime (extend internal/session/parse_test.go, os.Chtimes a fixture like TestParseSessionTail_FallsBackToModTimeWhenNoTimestamp does).
- The fzf/HTTP loop is thin glue; keep it small and manual-test it. Extract the reload-POST body/URL construction into a pure function and unit-test that.
Non-goals
- Grep/dir matcher modes inside
top.
- Status inference from transcript content (e.g. "waiting for user" vs "streaming") — mtime recency only for now; content-based status can layer on later.
- Watching for new sessions via fsnotify — periodic rescan is enough.
Acceptance criteria
ccsession top shows only sessions active within the window, refreshes automatically, preview follows the highlighted session, Enter resumes it.
ccsession list --active 15m works standalone and emits the status column.
- Default picker (
ccsession with no args) behavior and its column layout are completely unchanged.
gofmt -l . clean, go vet ./... clean, golangci-lint run clean, new tests pass with go test ./....
Problem
Running several agent sessions in parallel (Claude Code, Codex, ...) is now a normal workflow, but there is no way to see which sessions are active right now and what each one is doing.
ccsessionalready scans every transcript on disk; it only lacks an activity-oriented view.This issue adds
ccsession top: a live, auto-refreshing picker showing recently-active sessions, with the preview pane following the highlighted session's tail. Enter resumes the session, exactly like the default picker.UX spec
Row format (TSV, same column conventions as
ccsession list):RUNNING(green): activity within the last 60 seconds.IDLE(dim): activity within--windowbut older than 60 seconds.--windoware not listed.--intervalwithout the user pressing anything; the highlighted line and query are preserved across reloads (fzfreloadkeeps them).ccsession previewoutput, with--preview-windowset tofollowso a streaming session visibly scrolls.resumepath as the default picker).Implementation plan
1. Activity timestamp on
session.SessionFileMTime time.Timetosession.Session(internal/session/types.go).ParseSessionTail(internal/session/parse.go):readTailalready callsf.Stat()(parse.go:170) — capturefi.ModTime()there and thread it back, do not add a secondos.Stat.FileMTimeis preferred overLastTimebecause the JSONL is appended to continuously while an agent streams, so mtime moves even when the parsed tail timestamp lags.2.
list --active <duration>filter and status columnlist.Options(internal/list/list.go):Active time.Duration(zero = disabled, current behavior unchanged).Active > 0:now.Sub(s.ActiveAt()) > opts.Active;formatLineoutput:RUNNINGifnow.Sub(ActiveAt()) < 60*time.Second, elseIDLE. Color: green for RUNNING, faint for IDLE, following the existing color conventions ininternal/ansi.--active <duration>flag intocmdListincmd/ccsession/main.go(parse withtime.ParseDuration).toppasses its own fzf--with-nth/--nth, so the default picker script is untouched.3.
topsubcommand"top"in the subcommand switch incmd/ccsession/main.go(main.go:184).cmd/ccsession/top.go(or extend main.go following existing style) that:os.CreateTemp).exec.Commandwith:FZF_API_KEY=<key>andFZF_DEFAULT_COMMANDunset; input provided by--bind 'start:reload(<CCSESSION_BIN> list --active <window> --color=always)';--listen(no port argument — fzf picks a free one);--bind 'start:+execute-silent(echo $FZF_PORT > <portfile>)'— fzf exportsFZF_PORTto processes it spawns, this is the documented way to discover the port from outside;--preview '<CCSESSION_BIN> preview {N}'with--preview-windowincludingfollow;--bind 'enter:become(<CCSESSION_BIN> resume {N})'mirroring how the default script resumes;CCSESSION_BINself-invocation pattern fromdefaultScriptTmpl;CCSESSION_SOURCEinherits viaos.Environ()like today, sotopworks with any backend includingall.--intervalsendsPOST http://127.0.0.1:<port>/with headerx-api-key: <key>and bodyreload(<CCSESSION_BIN> list --active <window> --color=always).topreturns fzf's exit status like the default picker does (130 = cancel is not an error).--listenrequires fzf >= 0.42 andFZF_PORT/api-key semantics stabilized well before 0.58, which the README already requires — no new version constraint.4. Help text
topto the usage text incmd/ccsession/main.goand to the README feature list + usage section, including the--window/--intervalflags.Edge cases
--interval< 1s: reject with a clear error (avoid hammering scans).resumefor opencode/grok/codex works through the existingResumeSpecpath; nothing top-specific.--color=alwaysonly inside the fzf pipeline).Testing
RUNNING/IDLE/excluded) as a table-driven test over syntheticActiveAtvalues;--activefiltering ininternal/listwith fake sessions.ParseSessionTailpopulatesFileMTime(extendinternal/session/parse_test.go,os.Chtimesa fixture like TestParseSessionTail_FallsBackToModTimeWhenNoTimestamp does).Non-goals
top.Acceptance criteria
ccsession topshows only sessions active within the window, refreshes automatically, preview follows the highlighted session, Enter resumes it.ccsession list --active 15mworks standalone and emits the status column.ccsessionwith no args) behavior and its column layout are completely unchanged.gofmt -l .clean,go vet ./...clean,golangci-lint runclean, new tests pass withgo test ./....