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:
~/.omp/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)or equivalent direct read for file storage) 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 the same 4KB prefix per file, not the full JSONL file.
- Builds
SessionInfoobjects (id,cwd,title,messageCount,firstMessage,allMessagesText, timestamps). - Uses prefix parsing plus marker counting; later messages beyond the prefix may not be present in
allMessagesText. - 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 the last compactionshortSummaryseen in the 4KB prefixfirstMessageis first user message text discoverable from the prefix or"(no messages)"
--continue resolution and terminal breadcrumb preference
SessionManager.continueRecent(cwd, sessionDir?) resolves the target in this order:
- Read terminal-scoped breadcrumb (
~/.omp/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
-
Resume key value
resolveResumableSession(...)searches local sessions first, then all sessions whensessionDiris not forced- matching is case-insensitive and accepts
idprefix, full JSONL filename prefix, or the session-id suffix after the timestamp - first match in modified-descending order is used (no ambiguity prompt)
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
- Delete to delete after confirmation
- Esc to cancel
- Ctrl+C to exit
- fuzzy search across session id/title/cwd/first message/all messages/path
Empty-list render behavior:
- renders
No sessions in current folder. Press Tab to view all. - Enter/Delete on empty do nothing (no callback)
- Esc/Ctrl+C still work
Caveat: the empty-state UI mentions Tab, 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 display context via
buildDisplaySessionContext() - restore persisted/discovered MCP tool selections and rebuild active tools/system prompt when discovery is enabled
- emit
session_switchhook event (reason: "resume",previousSessionFile) - replace agent messages with rebuilt context and sync todos
- close provider sessions when switching to a different session or when same-session reload changed replay messages
- restore default model from
sessionContext.models.defaultif available and present in model registry - restore thinking level and service tier:
- thinking uses persisted
thinking_level_change, otherwise the configured default clamped to model capability - service tier uses persisted
service_tier_change, otherwise the configuredserviceTiersetting ("none"becomes unset)
- thinking uses persisted
- 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
- Matching uses
startsWithon the lowercased session id, lowercased JSONL filename, and lowercased id suffix after the filename timestamp. - First match in modified-descending order wins; there is no ambiguity UI if multiple sessions share a prefix.
- Prefix-listing metadata is intentionally lightweight, so search text may not include messages outside the first 4KB of the session file.