Session switching and recent session listing
This document describes how coding-agent discovers recent sessions, resolves --resume targets, presents session pickers, and switches the active runtime session.
It focuses on current implementation behavior, including fallback paths and caveats.
Implementation files
../src/session/session-manager.ts../src/session/agent-session.ts../src/cli/session-picker.ts../src/modes/components/session-selector.ts../src/modes/controllers/selector-controller.ts../src/main.ts../src/sdk.ts../src/modes/interactive-mode.ts../src/modes/utils/ui-helpers.ts
Recent-session discovery
Directory scope
SessionManager stores sessions under a cwd-scoped directory by default:
~/.pisces/agent/sessions/--<cwd-encoded>--/*.jsonl
SessionManager.list(cwd, sessionDir?) reads only that directory unless an explicit sessionDir is provided.
Two listing paths with different payloads
There are two different listing pipelines:
getRecentSessions(sessionDir, limit)(welcome/summary view)- Reads only a 4KB prefix (
readTextPrefix(..., 4096)) from each file. - Parses header + earliest user text preview.
- Returns lightweight
RecentSessionInfowith lazynameandtimeAgogetters. - Sorts by file
mtimedescending.
- Reads only a 4KB prefix (
SessionManager.list(...)/SessionManager.listAll()(resume pickers and ID matching)- Reads full session files.
- Builds
SessionInfoobjects (id,cwd,title,messageCount,firstMessage,allMessagesText, timestamps). - Drops sessions with zero
messageentries. - Sorts by
modifieddescending.
Metadata fallback behavior
For recent summaries (RecentSessionInfo):
- display name preference:
header.title-> first user prompt ->header.id-> filename - name is truncated to 40 chars for compact displays
- control characters/newlines are stripped/sanitized from title-derived names
For SessionInfo list entries:
titleisheader.titleor latest compactionshortSummaryfirstMessageis first user message text or"(no messages)"
--continue resolution and terminal breadcrumb preference
SessionManager.continueRecent(cwd, sessionDir?) resolves the target in this order:
- Read terminal-scoped breadcrumb (
~/.pisces/agent/terminal-sessions/<terminal-id>) - Validate breadcrumb:
- current terminal can be identified
- breadcrumb cwd matches current cwd (resolved path compare)
- referenced file still exists
- If breadcrumb is invalid/missing, fall back to newest file by mtime in the session dir (
findMostRecentSession) - If none found, create a new session
Terminal ID derivation prefers TTY path and falls back to env-based identifiers (KITTY_WINDOW_ID, TMUX_PANE, TERM_SESSION_ID, WT_SESSION).
Breadcrumb writes are best-effort and non-fatal.
Startup-time resume target resolution (main.ts)
--resume <value>
createSessionManager(...) handles string-valued --resume in two modes:
Path-like value (contains
/,\\, or ends with.jsonl)- direct
SessionManager.open(sessionArg, parsed.sessionDir)
- direct
ID prefix value
- find match in
SessionManager.list(cwd, sessionDir)byid.startsWith(sessionArg) - if no local match and
sessionDiris not forced, trySessionManager.listAll() - first match is used (no ambiguity prompt)
- find match in
Cross-project match behavior:
- if matched session cwd differs from current cwd, CLI prompts whether to fork into current project
- yes ->
SessionManager.forkFrom(...) - no -> throws error (
Session "..." is in another project (...))
No match -> throws error (Session "..." not found.).
--resume (no value)
Handled after initial session-manager construction:
- list local sessions with
SessionManager.list(cwd, parsed.sessionDir) - if empty: print
No sessions foundand exit early - open TUI picker (
selectSession) - if canceled: print
No session selectedand exit early - if selected:
SessionManager.open(selectedPath)
--continue
Uses SessionManager.continueRecent(...) directly (breadcrumb-first behavior above).
Picker-based selection internals
CLI picker (src/cli/session-picker.ts)
selectSession(sessions) creates a standalone TUI with SessionSelectorComponent and resolves exactly once:
- selection -> resolves selected path
- cancel (Esc) -> resolves
null - hard exit (Ctrl+C path) -> stops TUI and
process.exit(0)
Interactive in-session picker (SelectorController.showSessionSelector)
Flow:
- fetch sessions from current session dir via
SessionManager.list(currentCwd, currentSessionDir) - mount
SessionSelectorComponentin editor area usingshowSelector(...) - callbacks:
- select -> close selector and call
handleResumeSession(sessionPath) - cancel -> restore editor and rerender
- exit ->
ctx.shutdown()
- select -> close selector and call
Session selector component behavior
SessionList supports:
- arrow/page navigation
- Enter to select
- Esc to cancel
- Ctrl+C to exit
- fuzzy search across session id/title/cwd/first message/all messages/path
Empty-list render behavior:
- renders a message instead of crashing
- Enter on empty does nothing (no callback)
- Esc/Ctrl+C still work
Caveat: UI text says Press Tab to view all, but this component currently has no Tab handler and current wiring only lists current-scope sessions.
Runtime switch execution (AgentSession.switchSession)
switchSession(sessionPath) is the core in-process switch path.
Lifecycle/state transition:
- capture
previousSessionFile - emit
session_before_switchhook event (reason: "resume", cancellable) - if canceled -> return
falsewith no switch - disconnect from current agent event stream
- abort active generation/tool flow
- clear queued steering/follow-up/next-turn message buffers
- flush session writer (
sessionManager.flush()) to persist pending writes sessionManager.setSessionFile(sessionPath)- updates session file pointer
- writes terminal breadcrumb
- loads entries / migrates / blob-resolves / reindexes
- if missing/invalid file data: initializes a new session at that path and rewrites header
- update
agent.sessionId - rebuild context via
buildSessionContext() - emit
session_switchhook event (reason: "resume",previousSessionFile) - replace agent messages with rebuilt context
- restore default model from
sessionContext.models.defaultif available and present in model registry - restore thinking level:
- if branch already has
thinking_level_change, apply saved session level - else derive default thinking level from settings, clamp to model capability, set it, and append a new
thinking_level_changeentry
- if branch already has
- reconnect agent listeners and return
true
UI state rebuild after interactive switch
SelectorController.handleResumeSession performs UI reset around switchSession:
- stop loading animation
- clear status container
- clear pending-message UI and pending tool map
- reset streaming component/message references
- call
session.switchSession(...) - clear chat container and rerender from session context (
renderInitialMessages) - reload todos from new session artifacts
- show
Resumed session
So visible conversation/todo state is rebuilt from the new session file.
Startup resume vs in-session switch
Startup resume (--continue, --resume, direct open)
- Session file is chosen before
createAgentSession(...). sdk.tsbuildsexistingSession = sessionManager.buildSessionContext().- Agent messages are restored once during session creation.
- Model/thinking are selected during creation (including restore/fallback logic).
- Interactive mode then runs
#restoreModeFromSession()to re-enter persisted mode state (currently plan/plan_paused).
In-session switch (/resume-style selector path)
- Uses
AgentSession.switchSession(...)on an already-runningAgentSession. - Messages/model/thinking are rebuilt immediately in place.
- Hook
session_before_switch/session_switchevents are emitted. - UI chat/todos are refreshed.
- No dedicated post-switch mode restore call is made in selector flow; mode re-entry behavior is not symmetric with startup
#restoreModeFromSession().
Failure and edge-case behavior
Cancellation paths
- CLI picker cancel -> returns
null, caller printsNo session selected, process exits early. - Interactive picker cancel -> editor restored, no session change.
- Hook cancellation (
session_before_switch) ->switchSession()returnsfalse.
Empty list paths
- CLI
--resume(no value): empty list printsNo sessions foundand exits. - Interactive selector: empty list renders message and remains cancellable.
Missing/invalid target session file
When opening/switching to a specific path (setSessionFile):
- ENOENT -> treated as empty -> new session initialized at that exact path and persisted.
- malformed/invalid header (or effectively unreadable parsed entries) -> treated as empty -> new session initialized and persisted.
This is recovery behavior, not hard failure.
Hard failures
Switch/open can still throw on true I/O failures (permission errors, rewrite failures, etc.), which propagate to callers.
ID prefix matching caveats
- ID matching uses
startsWithand takes first match in sorted list. - No ambiguity UI if multiple sessions share prefix.
SessionManager.list(...)excludes sessions with zero messages, so those sessions are not resumable via ID match/list picker.