Session Files
September 6, 2026 · View on GitHub
cctop stores live session state as JSON files in ~/.cctop/sessions/. The menubar app treats these files as the local source of truth for what to render, while the hook binary updates them as tools emit lifecycle events.
Session files are intentionally local and inspectable. Missing optional fields must be treated as their default values so older files continue to load.
Identity
cctop_session_id
Type: string (lowercase UUID)
Default: absent only on legacy records awaiting migration.
cctop_session_id is an opaque identifier generated and owned by cctop for a
user session. It is random: it is never derived from the client, title,
project path, prompt or transcript content, PID, process generation, terminal,
window, or focus target. When a client supplies a supported durable resume
reference, the value remains stable across cctop and client restarts on the
same machine while cctop's local identity data remains. Otherwise it is
permanent only for that session record.
For supported resume contracts, cctop keeps a private UUID-only mapping under
~/.cctop/session-identities/. Mapping filenames hash the source-scoped client
reference; the raw reference is not copied into this directory. Existing
publishable session JSON files without the field are assigned an ID once and
stamped with the same per-file locking and atomic-write rules as hook updates.
Hidden, finished, cleanup, and history records keep their existing identity
contracts until a current hook, a stored manual hide, or an archived desktop
Recent projection needs this field.
Identity mappings intentionally outlive session and history cleanup and are not
automatically pruned in this version.
Deleting cctop's local session and identity data resets this continuity. Cross-machine sync is not supported.
Panel and keyboard navigation use this permanent ID as their logical identity. For a legacy record whose ID is still absent or invalid, they temporarily fall back to cctop's existing source- and host-aware session key. That fallback is never persisted as a replacement ID; indirect focus is allowed only when its current match is unique, otherwise the action fails closed.
harness_session_id
Type: string
Default: null when omitted.
The exact unsanitized session reference supplied to cctop-hook, byte-for-byte.
For integrations that expose a real conversation reference, this preserves it;
OpenCode intentionally continues to supply its process-scoped synthetic reference
until all of its per-session event payloads can be routed consistently. session_id is
sanitized to a restricted character set and truncated to 64 characters so it can
safely appear in file names and logs, which makes it a lossy projection of the
hook reference; harness_session_id preserves the original value for resume
lookup. It is evidence, not cctop identity. The hook stamps it after a matching event loads the record,
so records created by pre-field hooks gain it in place. A different conversation
may replace a PID-keyed record only through SessionStart-driven rotation.
Resume support
| Client | Same cctop ID after reopen/resume | Evidence |
|---|---|---|
| Codex CLI/Desktop | Yes | The client supplies the same UUID conversation reference across process generations. |
| Claude Code/Desktop | Yes when a UUID session reference is available | Claude session/transcript state preserves that UUID across resume. |
| pi | Yes only when getSessionId() supplies a real UUID | Synthetic pi-<pid> fallback records remain separate. |
| OpenCode | Not yet | The plugin intentionally uses a process-scoped synthetic reference until per-event session routing is reliable. |
Every new session record still receives a cctop_session_id. “Not yet” means
that cctop cannot promise the same ID for a later reopened record.
References are always source-scoped. cctop never infers that conversations
from different clients contain the same content.
Internal session model
The names separate decoded data from the work session that the user sees.
SessionDatais the Codable payload model for one hook-owned JSON file.SessionRecordcontains oneSessionDatavalue plus file and runtime evidence.- One or more related
SessionRecordvalues form aUserSession. displayRecord.datasupplies the visible row and the current target for indirect actions.
SessionDataCleanupSource is a cleanup-only projection derived from
SessionData. It is not a persisted SessionRecord or a UserSession.
The selected winner's valid cctop_session_id becomes the permanent identity
of at most one current UserSession. More than one SessionRecord can carry
that ID because one work session can have more than one file or host record.
Older retained records can contain a stale or conflicting ID, but they do not
redefine the current group. Hidden and finished records do not form a current
UserSession, even when they carry an ID.
The hook and identity store assign the ID to SessionData before the app forms
UserSession. CctopSessionID owns ID creation and validation. SessionData
only carries the serialized value.
SessionData.lifecycle is an existing display overlay. cctop derives it after
decode and never writes it to JSON. SessionRecord also stores the lifecycle
comparison rank, file path, and modification time. The record is not a
persisted schema.
SessionData replaces the old Session type name. The value describes one
file payload, not the full work session that the user sees. SessionRecord
replaces the old “observation” name because “record” describes its role more
clearly.
SessionDataSources is a separate dependency container for SessionManager.
It is not part of this three-layer model.
UserSession means one current, user-visible work session. It is not a user
account, a stored schema, or a client connection.
A work session can move between CLI and desktop surfaces of the same supported client when both records have matching identity evidence. cctop never groups different clients by project, title, prompt, or transcript content.
LogicalIdentity remains the name of the grouping rule. It uses the winner's
permanent cctop ID when that ID is valid. An unstamped legacy record uses a safe
fallback until it receives a permanent ID. It is not a separate session type.
Stable-key selection first chooses one authoritative record for each host conversation. That record sets the group identity. Older related records remain attached as evidence, even when their identity data is stale.
SessionManager.userSessions owns the canonical visible identity and row order.
It preserves prior UserSession order by LogicalIdentity while applying the
existing status-group rules. A presentation or direct-action leaf can read
UserSession.displayRecord.data, but it must not use that value to assign
identity, control order, or rebuild a UserSession.
Every session-file change starts a fresh forward rebuild: decode SessionData,
wrap it in SessionRecord, group records into UserSession, reconcile group
order, and then derive presentation data. cctop never flattens an existing
UserSession and uses that data to recreate identity or grouping.
Direct row actions keep the exact rendered SessionData. Indirect URL,
notification, hide, and keyboard actions resolve the current UserSession and
fail closed when the identity evidence is missing, invalid, or ambiguous.
Hidden, archived, finished, and cleanup-only records do not automatically enter
the UserSession projection.
Stream Deck routing
Display-state schema v2 publishes cctop_session_id for every session row.
Stream Deck caches the ID that a key rendered, so a press cannot accidentally
target an unrelated session that moved into the same slot. cctop then resolves
that permanent session ID to the canonical UserSession. It focuses the
session's focusTarget, which is the displayRecord.data selected by the
existing lifecycle preference. If no current user session matches, cctop records
a privacy-safe stale-state diagnostic. It performs one canonical reload and one
re-resolution, then fails closed if the target is still missing. It never falls
back to a client conversation reference, PID, or display slot.
SessionManager collapses visible records with the same logical identity into
one UserSession before applying its existing status-group ordering. It
reconciles current groups with the prior UserSession order by identity. The
existing lifecycle preference chooses displayRecord; it does not choose row
identity or order. Panel navigation, URL focus, notifications, hiding, and
DisplayStateWriter consume the ordered groups. Presentation code derives
SessionData only from displayRecord.data. DisplayStateWriter never sorts,
deduplicates, or removes manager groups, so Stream Deck slots remain a one-to-one
projection.
A direct pointer or context-menu action focuses the exact current record rendered in that row. Keyboard selection instead retains logical identity and resolves it against current user sessions, so reloads, focus-target changes, and status-group movement cannot retarget selection by row index. Navigate-mode number slots freeze those identities at activation; a missing identity leaves its original number unusable rather than shifting a later conversation into that slot.
This version does not offer user-selectable routing among multiple simultaneous
focus targets. Records sharing one ID form one user-session row. The
permanent-ID resolver finds that canonical UserSession and uses its
displayRecord as the focus target. The existing lifecycle preference selects
that record. This is the temporary single-target policy. Future support extends
the resolver with an explicit selection policy rather than changing logical
identity, lifecycle/liveness classification, canonical order, or terminal/window
execution.
Cross-client equivalence and cross-machine identity are out of scope. Manual
hiding and notification grouping use cctop_session_id. Archived desktop Recent
rows also prefer it for logical row identity; path-based Recent Projects, Cleanup,
and other persisted preferences keep their separate contracts.
Terminal Focus Metadata
terminal.multiplexer
Type: object
Default: null when omitted.
When present, terminal.multiplexer records pane or surface metadata for a
terminal multiplexer that hosts the session. cctop uses this to jump directly
to the right multiplexer target after focusing the host app.
Supported shapes:
{
"name": "cmux",
"socket": "/Users/me/.local/state/cmux/cmux.sock",
"workspace_id": "B48DBE7E-B98F-48E7-9914-17D7F119BEAA",
"surface_id": "0BEEE68A-A07D-4225-ACF6-8C973615AA91",
"binary_path": "/Applications/cmux.app/Contents/Resources/bin/cmux"
}
{
"name": "herdr",
"socket": "/Users/me/.config/herdr/herdr.sock",
"pane_id": "w1:p1",
"binary_path": "/opt/homebrew/bin/herdr"
}
{
"name": "zellij",
"session_name": "dev",
"pane_id": "terminal_3",
"binary_path": "/opt/homebrew/bin/zellij"
}
{
"name": "tmux",
"socket": "/tmp/tmux-501/default",
"pane_id": "%3",
"binary_path": "/opt/homebrew/bin/tmux"
}
All multiplexer fields are optional except the values needed for the specific
jump strategy. Older live cmux session files may not have
terminal.multiplexer; when the session process is still running and exposes
CMUX_* environment variables, the app can recover the cmux workspace and
surface at jump time without rewriting the session file.
terminal.focus_url
Type: string
Default: null when omitted.
When present, terminal.focus_url stores an app-specific session deep link.
Opening the link focuses the exact pane that hosts the session. Warp is the
only source today. Warp v0.2026.05.27 and newer export the link as
WARP_FOCUS_URL, shaped <channel-scheme>://session/<32 lowercase hex>.
The hook stores the value only when it matches that exact shape. The app
validates it again at focus time, because session files are user-writable.
The URL scheme names the Warp release channel: warp, warppreview,
warpdev, or warposs. Launch Services opens the link with that channel's
app and activates it. Warp ignores a stale or unknown session UUID, but the
window still comes forward. Sessions without a captured link keep plain app
activation, like the other terminals.
Visibility
hidden
Type: boolean
Default: false when omitted.
When hidden is true, cctop reads the session file but does not show that session in the active list, does not archive it into Recent Projects, and does not remove it during dead-session cleanup.
Use hidden for real session records that should remain on disk for liveness, debugging, or ownership tracking, but should not appear as user-facing work. Current examples include Codex memory-maintenance, project-suggestion, and title-generation helper sessions. Future cases can use the same attribute for background or delegated review sessions, such as Codex sessions summoned by Claude for review.
Do not use file deletion as the hiding signal. Delete a session file only when the session is genuinely obsolete and no longer useful as state.
Manual hiding
The app's user-triggered Hide Session action is separate from the session
file's hidden field. After confirmation, cctop stores only the session's opaque
cctop_session_id in local preferences; it does not rewrite the hook-owned JSON
or persist the session title, project path, prompts, or tool data.
Manual hiding removes the session from the panel, navigation, notifications, and Stream Deck output while the full record remains available for lifecycle and Cleanup tracking. There is no in-app restore. cctop prunes the preference only after a complete local inventory proves the session record is gone; partial or unreadable inventories retain it to avoid unexpectedly revealing the session. Finished manually hidden records remain exempt from lifecycle cleanup, so their permanent identity evidence stays available until the record is removed externally.
While a partial or unreadable inventory coexists with stored manual hides, the Recent and Cleanup projections stay frozen so a transient read failure never reveals a hidden session. Retained finished winners and archived desktop records remain available to Cleanup without entering Recent Projects. Unreadable pre-PID files survive while stored manual-hide evidence prevents proving them unrelated.
is_subagent
Type: boolean
Default: false when omitted.
When is_subagent is true, the session file represents a delegated subagent's own workspace rather than the user's top-level conversation. cctop marks these records hidden and keeps the file on disk. This is distinct from active_subagents, which belongs on the parent user-facing session to show how many delegated agents it currently owns.
Clients that can identify internal helper sessions should set is_subagent: true in their hook payloads. For Codex sessions, cctop decodes the structured threads.source value from Codex's local thread database: SessionSource::SubAgent(...) and SessionSource::Internal(...) are hidden, while cli and vscode remain user-visible even if the legacy diagnostic thread_source says subagent. thread_spawn_edges corroborates topology but is not the primary classifier, because review and guardian helpers may have no edge. Missing, malformed, unknown, or contradictory source evidence fails open and is counted in the session-load diagnostics.
Codex hooks do not yet expose semantic visibility or openability for ephemeral
root workers. cctop treats an explicitly null transcript_path on a positively
identified startup only as a short deferral signal, not as proof that a session
is internal. Resume, missing, unknown, or contradictory start-kind evidence fails
open. Hiding still requires
a narrow worker discriminator: the exact Codex-owned memories directory or the
project-suggestion prompt fragment at its observed fixed template position. The
memories directory is intentionally reserved for this classification. The
project-suggestion rule applies prospectively to hook events; existing records
fail open because transcript-field presence is not persisted. The preferred
upstream contract is an explicit user_openable/visibility field or structured
session source on every hook; ephemeral alone would describe persistence, not
whether a task belongs in the user-facing session list.
Older cctop versions may have persisted both is_subagent = true and hidden = true from thread_source alone. cctop clears that sticky pair only when structured source proves cli or vscode, the edge schema is readable and has no spawn edge for the thread, and no independent memory/title auto-hide rule applies.