11 · How a Session Resumes After Exit
Recover verifiable agent state after process exits, context compaction, or entry-point changes
Chapter brief
Question to answer
On session recovery, how do you restore cwd, permissions, tool side effects, and verification progress together?
By the end, you can
- Define state invariants that message history alone cannot restore
- Choose among JSONL, indexes, checkpoints, and lifecycle hooks
- Test truncated tails, restarts, duplicate events, and cross-entry resume
- Read this now if
- Engineers implementing durable sessions, resume, cross-entry recovery, or multi-platform routing
- Prerequisites
- Understand event logs, idempotency, and agent loops
- Deliverable
- A Session Restore specification, state inventory, and fault-injection suite
- Evidence boundary
- Source code shows payloads and order; it cannot replace recovery experiments with real filesystems and side effects
Define a real session recovery first
Section titled “Define a real session recovery first”Scenario: an agent in /repo-a on branch fix/payment edits two files and passes 7 of 9 tests, then the IDE crashes. The next day the user opens the same conversation from /repo-b and clicks Resume. Every message still exists. Without repository, branch, and permission checks, yesterday’s reasoning continues in today’s wrong workspace.
A recovery contract answers six questions:
| Recovery invariant | Acceptance check |
|---|---|
| Which logical task is this? | Stable thread_id; every process instance receives a new session_id |
| Where does it execute? | Refuse silent continuation when cwd, repository identity, Git SHA, or branch differs |
| What may it do? | Restore approval mode, sandbox policy, and tool profile; never infer permissions from chat text |
| What already happened? | Tool events carry operation ID, argument summary, commit state, and result-persistence state |
| What has been verified? | Todos, tests, reviewer findings, and verifier state restore or become explicitly invalid |
| Why resume or reset? | startup, resume, clear, compact, and idle reset remain visible lifecycle reasons |
A minimal SessionMeta can be small, but it cannot be only messages:
{ "thread_id": "thread_abc", "session_id": "session_002", "resumed_from": "session_001", "cwd": "/repo-a", "git_sha": "4f8c...", "approval_mode": "on-request", "tool_profile": "coding-read-write", "last_durable_event": 184, "verifier_checkpoint": "tests:7/9"}Fault injection: corrupt the final JSONL line, delete the SQLite index, change the current Git branch, and redeliver the last tool event. The system should recover safely, rebuild the index, or enter manual review—not merely remain conversational.
Compare four session models through that contract
Section titled “Compare four session models through that contract”| Recovery decision | Codex | Claude Code | OpenClaw | Hermes |
|---|---|---|---|---|
| Source of truth | Append-only JSONL rollout; SQLite is an index | Messages, cost, files, todos, worktree, and other subsystem state | Session identity and transcript contract; storage is upstream | SessionContext persisted by platform + chat_id |
| Identity model | ThreadId vs SessionId, with fork and archive | Session plus worktree, task, and sub-agent dimensions | Agent scope key plus session id | SessionSource plus platform, chat_id, and reset policy |
| Recovery trigger | Create / Resume recorder entry points | startup / resume / clear / compact lifecycle sources | Caller loads and switches by id | idle / daily / both / none reset |
| Primary risk | Event format and index migrations diverge | Cross-subsystem restore order becomes complex | Identity exists but recovery semantics remain undefined | Platform capabilities, PII, and reset rules keep diverging |
Source evidence: identity, storage, and lifecycle
Section titled “Source evidence: identity, storage, and lifecycle”Codex · Treat session as a database engineering problem: JSONL persistence + SQLite index + Thread/Session ID separation
Section titled “Codex · Treat session as a database engineering problem: JSONL persistence + SQLite index + Thread/Session ID separation”Start recovery design with a crash: a process can die while appending an event, updating an index, or after a tool side effect. Codex keeps append-only JSONL as the record, uses SQLite for lookup, and separates durable ThreadId from process SessionId.
That judgement produces three interlocking design decisions.
The first decision is JSONL append-only files rather than a single JSON file. The reason traces back to the realities of an agent process: it can be killed by the IDE, OOM-killed, Ctrl-C’d by the user, or wiped out by an OS restart, and the crash can land at any byte position during a write.
If you used a single JSON file, a crash mid-write corrupts the whole file beyond parsing; the next startup’s resume fails completely and the whole session is lost.
JSONL changes the failure mode: if an append fails mid-write on line N, complete earlier lines are usually still parseable. How much survives depends on write, flush/fsync, and filesystem semantics, so do not promise that exactly one line is lost.
Crash recovery goes from “all or nothing” to “lose at most one line”. A second benefit is write performance: a long session might have hundreds of turns; with a single JSON file every turn has to fully reserialize the multi-MB content and atomic-write it, and IO pressure grows linearly with turn count.
JSONL appends one line (a few KB) with one write syscall. A Codex snapshot reports a sub-5ms write example for a several-hundred-turn session; that is one environment observation, so measure it on the target filesystem.
A third benefit is streaming consumption: Codex’s TUI wants to display “what the agent is doing right now” in real time; JSONL can be tail -f’d, every new line is an event; a single JSON file cannot support this at all.
The second decision is the filename pattern: rollout-2025-05-07T17-24-21-5973b6c0-94b8-487b-a530-2aeb6098ae0e.jsonl.
The prefix is an ISO timestamp so listing the directory is already time-sorted; the suffix is a UUID to prevent collisions when multiple sessions are created in the same second; the hyphen separators let humans visually parse the timestamp.
This kind of “encode all routing info in the filename” design lets session management tools work without a database.
Finding the most recent session is just listing files in reverse order, finding a specific time window is just a prefix match, and archiving old sessions is moving files to an archived_sessions/ subdirectory.
The third decision is that the first line of every session must be a SessionMeta:
Codex codex/codex-rs/rollout/src/recorder.rs:80-105 RolloutRecorder takes Create / Resume params, writes JSONL through a tokio mpsc channel
/// Records all [`ResponseItem`]s for a session and flushes them to disk after/// every update.#[derive(Clone)]pub struct RolloutRecorder { tx: Sender<RolloutCmd>, writer_task: Arc<RolloutWriterTask>, pub(crate) rollout_path: PathBuf, event_persistence_mode: EventPersistenceMode,}
#[derive(Clone)]pub enum RolloutRecorderParams { Create { conversation_id: ThreadId, forked_from_id: Option<ThreadId>, source: SessionSource, thread_source: Option<ThreadSource>, base_instructions: BaseInstructions, dynamic_tools: Vec<DynamicToolSpec>, event_persistence_mode: EventPersistenceMode, }, Resume { // ... },}This SessionMeta contains every piece of metadata resume needs:
Codex codex/codex-rs/rollout/src/metadata.rs:39-65 Reconstruct ThreadMetadataBuilder from SessionMeta: cwd / model / agent / git
pub(crate) fn builder_from_session_meta( session_meta: &SessionMetaLine, rollout_path: &Path,) -> Option<ThreadMetadataBuilder> { let created_at = parse_timestamp_to_utc(session_meta.meta.timestamp.as_str())?; let mut builder = ThreadMetadataBuilder::new( session_meta.meta.id, rollout_path.to_path_buf(), created_at, session_meta.meta.source.clone(), ); builder.model_provider = session_meta.meta.model_provider.clone(); builder.agent_nickname = session_meta.meta.agent_nickname.clone(); builder.agent_role = session_meta.meta.agent_role.clone(); builder.agent_path = session_meta.meta.agent_path.clone(); builder.cwd = session_meta.meta.cwd.clone(); builder.cli_version = Some(session_meta.meta.cli_version.clone()); builder.sandbox_policy = SandboxPolicy::new_read_only_policy(); builder.approval_mode = AskForApproval::OnRequest; if let Some(git) = session_meta.git.as_ref() { builder.git_sha = git.commit_hash.as_ref().map(|sha| sha.0.clone()); builder.git_branch = git.branch.clone(); builder.git_origin_url = git.repository_url.clone(); } Some(builder)}Why does resume need so much metadata? Because replaying the message history alone is not enough.
When the model sees a message like “please rewrite the bar function in foo.py to be async”, it needs to know what the cwd was at the time (otherwise it cannot find foo.py), which model was running (different models behave differently and mixing them mid-conversation makes the dialogue incoherent), what the approval mode was (was it --accept-edits before but resume turned it back to interactive, getting stuck on every edit?), what the git commit was (the agent reasoned based on some specific commit, and if resume happens on a different branch the recommendations will be wrong).
If these hidden premises are not restored, the agent will appear to “have a different personality” and the user will conclude the resume feature is useless.
The fourth engineering decision is to pre-solve the performance problem of session count growth.
When sessions accumulate to hundreds or thousands, every startup that lists the directory and parses every JSONL’s first line to extract SessionMeta gets slow (IO cost + JSON parse cost).
On top of JSONL, Codex maintains a SQLite state.db for thread indexing: each thread’s metadata (cwd / model / created_at / last_message_at / archived flag) goes into a table, listing threads becomes a SQL query with millisecond response, and JSONL files remain the source of truth (if SQLite is corrupted you can rebuild from JSONL) but day-to-day queries go through SQLite.
Startup does not scan all files; only when a thread_id is missing from SQLite does it backfill once. This “files as durable layer + database as index layer” split is an established option, but its recovery and migration behaviour still needs testing in the target storage environment.
The fifth engineering decision is splitting “logical conversation” and “concrete runtime instance” into two independent IDs.
ThreadId is the logical conversation unit: when a user says “that refactoring conversation I had”, they mean a thread; a thread can span multiple process launches, can be forked (start a new branch based on existing history), and can be archived (moved into archived_sessions/).
SessionId is one concrete runtime instance: every time the process starts, that is one session, scoped to the process lifetime, and the user does not care about nor see the specific session_id.
A thread might span multiple sessions (each resume creates a new session inheriting the same thread_id).
This separation lets the user view (persistent thread) and the system view (transient session) evolve independently, avoiding confusion.
The Session in memory is a locked state machine:
Codex codex/codex-rs/core/src/session/session.rs:11-37 Session is a locked state machine: state Mutex + active_turn Mutex + Mailbox + services bundle
/// Context for an initialized model agent////// A session has at most 1 running task at a time, and can be interrupted by user input.pub(crate) struct Session { pub(crate) conversation_id: ThreadId, pub(crate) installation_id: String, pub(super) tx_event: Sender<Event>, pub(super) agent_status: watch::Sender<AgentStatus>, pub(super) out_of_band_elicitation_paused: watch::Sender<bool>, pub(super) state: Mutex<SessionState>, pub(super) managed_network_proxy_refresh_lock: Semaphore, pub(super) features: ManagedFeatures, pub(super) pending_mcp_server_refresh_config: Mutex<Option<McpServerRefreshConfig>>, pub(crate) conversation: Arc<RealtimeConversationManager>, pub(crate) active_turn: Mutex<Option<ActiveTurn>>, pub(super) mailbox: Mailbox, pub(super) mailbox_rx: Mutex<MailboxReceiver>, pub(super) idle_pending_input: Mutex<Vec<ResponseInputItem>>, pub(crate) goal_runtime: GoalRuntimeState, pub(crate) guardian_review_session: GuardianReviewSessionManager, pub(crate) services: SessionServices, pub(super) next_internal_sub_id: AtomicU64,}The comment “A session has at most 1 running task at a time, and can be interrupted by user input” is the core invariant: one session can only run one turn at a time, user input can interrupt the current turn, but two turns cannot run concurrently.
This invariant prevents race conditions. If two turns simultaneously wrote to message history, called tools, and modified files, the state would be a mess.
The trade-off is that a single session cannot serve concurrent user requests in parallel.
Codex compensates with a more aggressive strategy: if the user wants parallelism, they open a new thread (fork the current thread), and the new thread is an independent session running an independent turn with no interference.
Claude Code · Split session into 22 subsystems + 4 lifecycle events firing hooks
Section titled “Claude Code · Split session into 22 subsystems + 4 lifecycle events firing hooks”An IDE session is more than a message file. Claude Code separates history, cost, todos, worktree state, and other subsystems, then maps startup, resume, clear, and compact to different hooks. Restore order becomes part of the contract.
The starting point: session is not one thing but a collection of mutually independent subsystems (message history is one subsystem, the cost tracker another, attribution another, todo list another, worktree state another, file history another…), each with its own storage format, lifecycle, and restore logic.
So Claude Code splits session across 22 files, each with session in its name: sessionStart manages startup, sessionRestore manages restoration, sessionStorage manages persistence, sessionState manages runtime state, sessionMemory manages the in-memory message buffer, sessionMemoryCompact manages context compression, sessionRunner manages turn execution, sessionIngress manages entry points (IDE / CLI), sessionEnvVars manages env vars, sessionEnvironment manages runtime environment, sessionActivity manages activity detection (idle judgement), sessionHistory manages historical logs, sessionFileAccessHooks manages file access hooks, sessionHooks manages lifecycle hook registration, sessionTracing manages OTEL tracing, sessionUrl manages IDE jump URLs, sessionTitle manages display titles, sessionIngressAuth manages ingress auth, sessionIdCompat manages legacy ID compat, sessionStoragePortable manages cross-device storage, SessionsWebSocket manages IDE WebSocket.
The benefit of this split is that each subsystem can evolve independently. Adding a new feature (e.g. “record which skills the user used”) only requires adding a sessionSkillUsage subsystem without touching the others.
The flip side is that resume becomes complex: you have to restore 22 subsystems’ worth of state in the right order, and getting one wrong makes the agent misbehave.
The central abstraction collapses all session lifecycle events into 4 sources, each source firing a set of plugin hooks + user hooks:
Claude Code claude-code/src/utils/sessionStart.ts:34-66 processSessionStartHooks with 4 sources: startup / resume / clear / compact
// Note to CLAUDE: do not add ANY "warmup" logic. It is **CRITICAL** that you do not add extra work on startup.export async function processSessionStartHooks( source: 'startup' | 'resume' | 'clear' | 'compact', { sessionId, agentType, model, forceSyncExecution, }: SessionStartHooksOptions = {},): Promise<HookResultMessage[]> { // --bare skips all hooks. executeHooks already early-returns under --bare // (hooks.ts:1861), but this skips the loadPluginHooks() await below too — // no point loading plugin hooks that'll never run. if (isBareMode()) { return [] } const hookMessages: HookResultMessage[] = [] const additionalContexts: string[] = [] const allWatchPaths: string[] = []
// Skip loading plugin hooks if restricted to managed hooks only // Plugin hooks are untrusted external code that should be blocked by policy if (shouldAllowManagedHooksOnly()) { logForDebugging('Skipping plugin hooks - allowManagedHooksOnly is enabled') } else { // Ensure plugin hooks are loaded before executing SessionStart hooks. try { await withDiagnosticsTiming('load_plugin_hooks', () => loadPluginHooks()) } catch (error) { // Log error but don't crash - continue with session start without plugin hooksThese 4 sources express 4 completely different scenarios. startup is “brand-new conversation”: the user types claude for the first time, with no history; what hooks should do is load the CLAUDE.md project description, set the working directory, initialise the cost tracker, and inject certain system prompt sections per plugin config; what they should not do is pull from archive (there isn’t any) or restore worktree state (the user did not ask for worktree). resume is “continue from historical session”: the user types claude --resume and picks a past conversation; what hooks should do is restore cost state, attribution snapshot, file history, todos, model override, and worktree state; what they should not do is reset the cost tracker (resume means continue, not start from zero) or reload CLAUDE.md (already in history). clear is “user-issued /clear”: during a conversation the user wants to reset context while preserving session metadata; what hooks should do is clear message history, preserve the cost tracker (billing should not reset), and preserve the model override (user preference is stable); what they should not do is delete the session file (the user might resume later). compact is “context exceeded threshold, triggering compression”: the system judges context tokens > limit and fires the compact subagent; what hooks should do is snapshot critical info (to avoid losing it post-compression) and pause cost tracker writes (compact’s own LLM-call billing must be separated); what they should not do is clear message history (compact is “condense” not “discard”).
Merging these 4 sources is tempting: startup and resume are both “begin a session”, clear and compact are both “mid-session events”, so on the surface merging into 2 looks cleaner.
But each source has its own do/don’t list, and merging them forces hooks to write if-else branches inside themselves to detect the current case, which is actually more verbose than keeping them separate.
The cited snapshot fixes these four sources as a union type. The code proves that they route to different hook semantics, not that four is a universal minimum. Before adding or merging an event, list the side effects that must differ.
One source comment states: “do not add ANY ‘warmup’ logic. It is CRITICAL that you do not add extra work on startup.” It establishes a maintenance rule, but not a historical list of warmups or measured startup times. To validate the rule for a product, benchmark directory scans, plugin loading, git queries, and version checks separately under cold and warm caches, then choose synchronous, background, or lazy execution from those traces.
Resume is not “reload the JSONL”: it has to restore 7 categories of state in order:
Claude Code claude-code/src/utils/sessionRestore.ts:1-58 sessionRestore touches 7 subsystems: cost / attribution / fileHistory / todos / model / worktree / systemPrompt
import { feature } from 'bun:bundle'import type { UUID } from 'crypto'import { dirname } from 'path'import { getMainLoopModelOverride, getSessionId, setMainLoopModelOverride, setMainThreadAgentType, setOriginalCwd, switchSession,} from '../bootstrap/state.js'import { clearSystemPromptSections } from '../constants/systemPromptSections.js'import { restoreCostStateForSession } from '../cost-tracker.js'import type { AppState } from '../state/AppState.js'import type { AgentColorName } from '../tools/AgentTool/agentColorManager.js'import { type AgentDefinition, type AgentDefinitionsResult, getActiveAgentsFromList, getAgentDefinitionsWithOverrides,} from '../tools/AgentTool/loadAgentsDir.js'import { TODO_WRITE_TOOL_NAME } from '../tools/TodoWriteTool/constants.js'import { asSessionId } from '../types/ids.js'import type { AttributionSnapshotMessage, ContextCollapseCommitEntry, ContextCollapseSnapshotEntry, PersistedWorktreeSession,} from '../types/logs.js'import type { Message } from '../types/message.js'import { renameRecordingForSession } from './asciicast.js'import { clearMemoryFileCaches } from './claudemd.js'import { type AttributionState, attributionRestoreStateFromLog, restoreAttributionStateFromSnapshots,} from './commitAttribution.js'import { updateSessionName } from './concurrentSessions.js'import { getCwd } from './cwd.js'Every import corresponds to a state category that must be restored: cost-tracker is how much money has been spent, commitAttribution tracks which changes the user wrote vs the agent wrote, AgentTool/agentColorManager handles subagent colour coding, TodoWriteTool tracks the todo list, AppState manages app-level UI state, claudemd cache clearing, worktree-related types handle git worktree session state.
Missing any one of these makes the agent inconsistent on that dimension: cost not restored gives the user the illusion of “billing reset to zero”, attribution not restored loses the “Co-authored-by Claude” on git commits, todos not restored loses the user’s outstanding todo items from last time, worktree state not restored means the agent doesn’t know which worktree to operate in.
This “resume complexity” is the price of an IDE-grade session. State is deliberately distributed across subsystems so each evolves independently, and resume has to orchestrate across them.
OpenClaw · Reduce session to a pure identity concept: only validate the ID, leave storage to upstream
Section titled “OpenClaw · Reduce session to a pure identity concept: only validate the ID, leave storage to upstream”When a framework only defines identity, it should not force a storage backend. OpenClaw validates session IDs, builds a scope key, and serialises a transcript; persistence and resume semantics stay upstream.
OpenClaw openclaw/src/sessions/session-id.ts:1-6 session id is a UUID regex, full stop
export const SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export function looksLikeSessionId(value: string): boolean { return SESSION_ID_RE.test(value.trim());}Just that regex and a helper function. The rest of the session module is at the same “small utilities” level: session-key-utils.ts prepends agent scope to the session key ({agentId}:{sessionId} format, so the same session can be distinguished from different agent perspectives), session-label.ts generates human-readable labels (for UI), transcript-events.ts handles transcript event serialization, model-overrides.ts and level-overrides.ts provide per-session config overrides, send-policy.ts manages outbound message policy.
The whole module has no rollout files, no SQLite index, no lifecycle hooks, and does not even specify where the session should be stored.
This minimalism looks “incomplete”, but it is actually a consequence of OpenClaw’s positioning. OpenClaw is a framework (agent platform), not a product (agent product): its users are developers writing agents, not end users using agents.
These two audiences want completely different things from session. End users need product features like “list past 7 days of conversations”, “resume any session”, “auto-archive old sessions”; developers each have their own storage stack.
Someone writing a Slack bot wants to store session in a Slack thread, someone writing an IDE plugin wants to store it in IDE workspace state, someone writing SaaS wants to store it in PostgreSQL / Redis, someone writing a CLI tool wants local JSONL.
If OpenClaw mandated one storage approach at the framework layer, all these scenarios would be cut off.
So OpenClaw validates only ID format compliance, provides only the session_key namespace utility, provides only the transcript event serialization format, and leaves “where it lives, when it resets, how it resumes” entirely to the upstream caller.
This is a “define contract, not implementation” framework design philosophy, analogous to how a database’s query layer should not decide where data lives (SQLite / PostgreSQL / MySQL backends are interchangeable, but the query layer is unified).
The cost is poorer out-of-box experience (demoing a complete agent requires choosing a session backend first) and possible ecosystem fragmentation (different plugins might store to different places); the benefit is OpenClaw can adapt to any deployment shape without changing framework code.
Hermes · Designed for multi-platform chat: SessionSource records where messages come from + 4 reset modes
Section titled “Hermes · Designed for multi-platform chat: SessionSource records where messages come from + 4 reset modes”Multi-channel chat must know which platform, conversation, and redaction rules produced a message. Hermes stores those fields in SessionSource and applies a reset policy for idle or scheduled boundaries.
From the user’s perspective these are “different sub-threads with the same agent”, but at the system level they must be completely independent sessions.
The contents of a Telegram DM must not bleed into a Slack workspace (privacy / compliance), and a Discord public channel’s content must not pollute a Telegram DM (context confusion).
So Hermes’s session design has two core abstractions. The first is SessionSource, which records “where each message came from”:
Hermes hermes-agent/gateway/session.py:65-106 SessionSource = platform + chat_id + user / chat metadata, covering 4 chat types (DM / group / channel / thread)
@dataclassclass SessionSource: """ Describes where a message originated from.
This information is used to: 1. Route responses back to the right place 2. Inject context into the system prompt 3. Track origin for cron job delivery """ platform: Platform chat_id: str chat_name: Optional[str] = None chat_type: str = "dm" # "dm", "group", "channel", "thread" user_id: Optional[str] = None user_name: Optional[str] = None thread_id: Optional[str] = None chat_topic: Optional[str] = None user_id_alt: Optional[str] = None # Signal UUID chat_id_alt: Optional[str] = None # Signal group internal ID is_bot: bool = False
@property def description(self) -> str: """Human-readable description of the source.""" if self.platform == Platform.LOCAL: return "CLI terminal" # ...This dataclass has to answer three questions: which routing path does a reply take (platform + chat_id determine where replies go), what context should be injected into the system prompt (so the agent knows “you are in a Slack workspace right now, be more professional; you are in a Telegram DM right now, be more casual”), and where should cron task output be delivered (if a user asks “remind me to attend a meeting at 8am tomorrow”, the agent has to proactively send to the right platform and chat the next morning).
Note that chat_type covers 4 chat shapes: dm (direct message), group (regular group), channel (public channel), thread (threaded conversation), and the agent’s behaviour should differ per type: DM allows uninhibited dialogue, group requires restraint to avoid spamming, channel demands more formality.
There are also Signal-specific user_id_alt and chat_id_alt fields. Signal’s protocol uses phone numbers in groups but sometimes UUIDs too, and both must be stored to route correctly.
The second core abstraction is the 4 reset modes provided by SessionResetPolicy:
Hermes hermes-agent/gateway/config.py:100-141 SessionResetPolicy 4 modes: daily / idle / both / none, overridable per platform or chat type
@dataclassclass SessionResetPolicy: """ Controls when sessions reset (lose context).
Modes: - "daily": Reset at a specific hour each day - "idle": Reset after N minutes of inactivity - "both": Whichever triggers first (daily boundary OR idle timeout) - "none": Never auto-reset (context managed only by compression) """ mode: str = "both" # "daily", "idle", "both", or "none" at_hour: int = 4 # Hour for daily reset (0-23, local time) idle_minutes: int = 1440 # Minutes of inactivity before reset (24 hours) notify: bool = True # Send a notification to the user when auto-reset occurs notify_exclude_platforms: tuple = ("api_server", "webhook")These 4 modes correspond to 4 real user populations. daily is “reset at a fixed time every day”.
Typical users are personal-assistant users with regular schedules: they use the agent for a stretch in the morning, do not use it at night, and the default 4am at_hour auto-resets to start the next day fresh.
The benefit is a clean session every day with no garbage context accumulation; the cost is that night-owl users might suddenly lose memory at 4am. idle is “reset after idle timeout”, and its typical users are in project-collaboration settings: discussing a project with the agent in a Slack workspace, replying once every few days, but keeping context as long as activity continues.
The benefit is “active-engagement” granularity, where context persists across consecutive discussion of the same project; the cost is that crossing the idle threshold forces a reset that may annoy the user. both is “either trigger resets”, and it is Hermes’s default, combining daily-cleanup stability with workday continuity. Whether it fits your users belongs in session-interval data, not a default claim. none means “never auto-reset”.
Its typical users are people maintaining long-term projects (novel writing, knowledge-base curation), where context persists forever and the agent truly “remembers” what the user has been doing.
The cost is unbounded context growth, so it must rely on the compact mechanism as a safety net.
The notify_exclude_platforms field is a very practical detail. On reset, the user is notified by default (“the agent has reset its context”) so they understand why the agent suddenly forgot earlier work; but for api_server and webhook (program callers), the notification is meaningless noise, so those two platforms are excluded by default.
Hermes also has a feature the other three don’t: per-platform PII (personally identifiable information) redaction:
Hermes hermes-agent/gateway/session.py:176-209 Safe-platform allowlist + opt-in PII redaction: replace phone numbers / user IDs with hashes before the LLM sees them
_PII_SAFE_PLATFORMS = frozenset({ Platform.WHATSAPP, Platform.SIGNAL, Platform.TELEGRAM, Platform.BLUEBUBBLES,})"""Platforms where user IDs can be safely redacted (no in-message mention systemthat requires raw IDs). Discord is excluded because mentions use ``<@user_id>``and the LLM needs the real ID to tag users."""
def build_session_context_prompt( context: SessionContext, *, redact_pii: bool = False,) -> str: """ Build the dynamic system prompt section that tells the agent about its context.
This is injected into the system prompt so the agent knows: - Where messages are coming from - What platforms are connected - Where it can deliver scheduled task outputs
When *redact_pii* is True **and** the source platform is in ``_PII_SAFE_PLATFORMS``, phone numbers are stripped and user/chat IDs are replaced with deterministic hashes before being sent to the LLM. Platforms like Discord are excluded because mentions need real IDs. Routing still uses the original values (they stay in SessionSource). """The comment makes “why Discord can’t redact” very clear. Discord’s mention syntax is <@user_id> (must use numeric ID), Slack’s mention syntax is <@U12345678> (must use Slack member ID); if these platforms had user_id replaced with a hash before sending to the LLM, the LLM would not be able to generate correct mentions in its response, and the agent-to-user product signal of “I am talking to you” would be lost.
WhatsApp, Signal, Telegram, BlueBubbles use natural-language mentions (@username / phone number) that do not depend on internal IDs, so they are safe to redact.
This is a concrete conclusion landed after product and security needs tug at each other, not abstract design. Putting “why this way” directly in the code comments is a level of engineering transparency worth learning.
The design at the heart of the whole PII system is “routing path separated from LLM path”: SessionSource always retains the raw user_id and chat_id for routing (Hermes’s system layer knows the real IDs so it can route replies to the right place), but the prompt sent to the LLM has those IDs replaced with deterministic hashes (hash_user_001 / hash_chat_001).
When the LLM generates a reply referencing hash_user_001, before Hermes routes that reply out it maps the hash back to the real user_id and then sends.
This “LLM doesn’t know real IDs but the system does” design is extremely useful in enterprise deployments.
Checks shared by session flows
Section titled “Checks shared by session flows”Despite wildly different implementation depth, three questions recur in the source snapshots. They are this article’s synthesis, not a claim that every product enforces one universal rule.
The first is that every session must have a globally unique ID. Codex uses UUIDv4 (128 bits, sufficient for collision avoidance), Claude Code uses UUIDv4 with an additional worktree-dimension qualifier, OpenClaw strictly validates the UUID format, Hermes uses platform+chat_id as a natural unique identifier. Why must it be unique? Because the harness persists session data to the filesystem, database, and remote KV, and collisions cause one session’s data to overwrite another’s, producing bugs that are extremely hard to track down. Multiple sessions may live concurrently in different processes or different devices, and routing them correctly is impossible without globally unique IDs.
The second is that sessions must bind to a user / scope and cannot be global. Codex puts agent_path and agent_nickname in SessionMeta (different agent roles on the same machine need to be distinguishable), Claude Code uses worktreeSession to give each git worktree an independent session (when one project has multiple worktrees working concurrently, sessions cannot mix), OpenClaw uses {agentId}:{sessionId} key concatenation (the same sessionId is fully isolated across agent perspectives), Hermes uses platform+chat_id to naturally separate by platform and chat context. The flip side is a “global session pool”: if all users, agents, and projects shared one session set, sessions would pollute each other; preferences the agent learned for user A would be wrongly applied to user B.
The third is that resume cannot just replay messages: hidden state may also need restoration. This is where resume looks simple but is easy to get wrong: the model may see that the user asked to modify foo.py without knowing the original cwd, approval mode, or Git state. The implementations differ: Codex records these fields in SessionMeta, Claude Code restores several subsystems, OpenClaw provides a transcript-events contract and leaves storage to the caller, and Hermes rebuilds SessionContext. A target system should document which hidden premises are part of its resume contract.
Choose storage by recovery need
Section titled “Choose storage by recovery need”The agreements establish the floor, but the divergences on “how deep should session go” are what actually decide which approach fits which scenario. Reframing through “what kind of agent are you building” shows which design to borrow.
If you want a long-lived developer-tool agent where users expect to list all conversations from the past months, resume any one, and name/archive/categorise them, Codex’s JSONL + SQLite two-tier architecture is a candidate to study. The core need in this scenario is “complete persistence, controllable performance, cross-time references”, and Codex’s design addresses each: JSONL supports crash recovery, the SQLite index supports indexed queries, ThreadId gives a stable identity across processes, and archived_sessions/ separates older sessions. The price is real engineering complexity: you maintain the JSONL format, the SQLite schema, and their consistency; validate recovery and query boundaries on the target repository.
If you want an IDE-integrated agent that needs to coordinate deeply with first-class IDE subsystems like cost tracker, file change tracking, todo list, and worktree, Claude Code’s 22-file layout + 4 lifecycle sources is a candidate to study. The core need is “let session coordinate with the IDE’s other state systems rather than replace them”, and Claude Code’s design has each subsystem evolve independently, 4 source hooks let plugins precisely choose when to engage, and the warmup ban keeps startup latency under control. You inherit heavy long-term maintenance: 22 files, plus order-sensitive restoration of 7 subsystem states; validate that complexity with restore-order and startup traces before adopting it.
If you want an agent framework rather than an agent product, and don’t want to force a storage solution on users, OpenClaw’s minimal session-id is a candidate to study. The core need is “define a clear contract, leave the implementation to users”, and OpenClaw only validates UUID format + provides session_key namespace + provides transcript serialization, with everything else handed upstream. You trade away out-of-box polish: users must pick a backend before they can get started; the upstream layer must define storage, resume, and consistency semantics.
If you want a multi-platform chat agent simultaneously serving Telegram, Slack, Discord, Hermes’s SessionSource + 4 reset modes + PII safe-platform list is a candidate to study. The core need is “precisely model where messages come from, customise reset policy per platform and scenario, distinguish whether redaction is possible by platform capability”, and Hermes’s design addresses each: SessionSource encodes message origin, SessionResetPolicy provides per-platform reset modes, and _PII_SAFE_PLATFORMS separates mention syntax. The burden shifts to owning compatibility across platforms and drawing a clean boundary between reset policy and compact; validate it with platform-specific fixtures.
Choice: recovery semantics precede storage
Section titled “Choice: recovery semantics precede storage”There is no star rating here. Define what must remain invariant after resume before choosing storage and lifecycle layers.
| Recovery constraint | Start with | Cost or boundary |
|---|---|---|
| History must support list, resume, fork, and archive | Codex JSONL, SQLite index, and separate Thread/Session IDs | File format and index migrations must stay consistent |
| Session state must coordinate with IDE subsystems and hooks | Claude Code source hooks and restore layers | Restore order affects behavior |
| A framework should define identity but not storage | OpenClaw session IDs, scope keys, and transcript contract | Storage and resume semantics move upstream |
| Multi-channel chat needs source-aware reset and redaction | Hermes SessionSource, reset policy, and PII list | Each platform has different capabilities |
Build the smallest session lifecycle
Section titled “Build the smallest session lifecycle”Define the resume invariants first, then choose JSONL, indexing, and lifecycle layers by recovery risk; gate each layer with truncated-tail, restart, and multi-platform fixtures.
Build recipe
最小可行
- UUID v4 for session_id (borrow from OpenClaw's regex validation): it provides a large random space; combine format validation with a storage-level uniqueness constraint so dirty or repeated IDs cannot overwrite records
- One JSONL file per session (borrow from Codex format: rollout-{ts}-{uuid}.jsonl): append one event per line. A recovery parser can skip an incomplete tail, while durability still depends on buffering, fsync, filesystem semantics, and the write protocol
- First line writes SessionMeta: cwd / model / agent / git_sha / timestamp: this is the only source for resume to restore runtime environment; the model doesn't need to see (meta is for the harness)
- On resume, look up by session_id and rehydrate cwd / model from SessionMeta: not just restoring messages but also the environment (working directory / model selection / approval mode) goes back to that moment; otherwise agent has "amnesia"
进阶
- Add a SQLite state DB for thread indexing (borrow from Codex) after measuring directory-scan latency and session count; include backfill and schema migration in recovery tests
- Distinguish ThreadId vs SessionId (borrow from Codex): thread is the logical conversation (user perspective's "this chat"), session is one concrete run instance (one startup + exit physical cycle); on resume one thread can correspond to multiple sessions
- 4 lifecycle sources (borrow from Claude Code): startup / resume / clear / compact, each fires hooks: different lifecycle events need different handling (startup loads user preferences / resume restores cwd / clear empties messages / compact compresses history)
- sessionRestore across subsystems: not just messages but also restoring cost (continue accumulating not zeroed) / attribution (which actions are this user's) / fileHistory (edit history) / todos (task list) / worktree (git branch) / model (model selection); missing any one causes "blackout"
- archived_sessions/ as separate subdirectory for archived sessions (borrow from Codex): current sessions physically separated from historical ones, listing current doesn't scan history; archive both controls file count and preserves history queryable
- Multi-platform routing via SessionSource (borrow from Hermes' platform + chat_id): routing info separated from LLM input (don't let model see "am I on telegram or slack"); same conversation across platforms knows which platform it was on
- 4-mode SessionResetPolicy (borrow from Hermes): daily (daily reset) / idle (idle 30min reset) / both (either condition triggers) / none (never auto-reset), overridable per platform: different scenarios need different strategies (customer service daily / long-running assistant none)
- PII redaction respects platform capabilities (borrow from Hermes' _PII_SAFE_PLATFORMS): mention-based platforms can't redact (@Alice changed to [REDACTED] users can't find Alice), name-based platforms can redact; safety strategy varies by platform feature
- Ban unbenchmarked warmup work in comments (borrow from Claude Code's "do not add warmup"): set cold- and warm-cache startup budgets, require traces for additions, and prefer background work or lazy loading
一开始别做
- Don't bury session metadata inside message history: model resume seeing meta info confuses it, meta should go to SessionMeta (separate field); mixing pollutes the prompt and makes individual updates hard
- Don't repeatedly rewrite a whole-session JSON file without measuring it: serialization and atomic replacement grow with file size; compare write cost, corruption recovery, and schema evolution against JSONL or a database
- Don't assume session_id uniqueness from caller: malicious callers may repeat IDs trying to overwrite others' sessions; regex validation + database unique constraint both required
- Don't let resume only replay messages: cwd / model / approval mode not restored, agent has "amnesia" (user asks "what directory were you just in?" can't answer)
- Don't put network / large file scans in the startup hook: Claude Code's "do not add warmup" rule is hard-earned; startup slow ruins UX, all warmup should go through lazy loading not startup
Put state differences back in context
Section titled “Put state differences back in context”Lined up, the engineering-direction spread is one glance: file-level persistence (Codex) -> subsystem decomposition (Claude Code) -> minimal ID (OpenClaw) -> multi-platform routing (Hermes).
Next experiment: corrupt the tail, switch branches, and duplicate an event
Section titled “Next experiment: corrupt the tail, switch branches, and duplicate an event”Do not test only one clean Resume. Build four fixtures:
- Corrupt tail: truncate the final JSONL record; retain earlier complete events and mark the tail for review.
- Missing index: delete the SQLite index; rebuild it from the event source of truth instead of losing the thread.
- Environment drift: switch repository or Git branch; block silent continuation and show the mismatch.
- Duplicate event: redeliver the last tool operation ID; do not repeat the side effect.
The acceptance report records the recovered event, invalidated state, whether a tool or verifier reran, and when recovery escalated to a human. A chat window that merely continues rendering is not a passing recovery test.
Evidence for session recovery and reset
Section titled “Evidence for session recovery and reset”Appendix: exercises and review
Section titled “Appendix: exercises and review”Open the exercises and ten review questions
Exercises
Section titled “Exercises”- Easy: define a SessionMeta record capturing session-startup metadata: cwd, model, git_sha, agent_role, timestamp. Write it as the first JSONL line when the session opens.
- Medium: measure directory-scan latency as session count grows. When it exceeds the product budget, add a SQLite table for thread_id / cwd / timestamp / last_message_at and backfill on demand.
- Medium: implement
processSessionLifecycle(source)where source is in{startup, resume, clear, compact}. Each source calls a different hook set. Verify:clearresets the cost tracker,resumedoes not. - Hard: implement SessionResetPolicy with all four modes (daily / idle / both / none).
idle_minutescompares againstlast_message_at;dailychecks whether local time has crossedat_hour. On reset, fire a notification (excluding api_server / webhook).
Review questions
Section titled “Review questions”Q1 · Concept: Why does Codex use JSONL append-only instead of a single JSON file for sessions?
JSONL beats whole-file JSON for agents on three specific axes:
1. Crash recovery.
Agent processes can be SIGKILLed, lose power, OOM, or be terminated by the IDE. Whole-file JSON: a crash mid-write corrupts the entire file; the next startup cannot parse it; the session is gone.
JSONL append-only: every line parses independently. A recovery parser can skip an incomplete or invalid tail and retain earlier events that reached storage. Whether damage is limited to one line still depends on buffering, fsync, and filesystem semantics.
The Codex snapshot describes a handful of corrupted files per week and 99.9% recoverability. This site has not reproduced that ratio; treat it as a source-side observation and measure corruption on the target filesystem.
2. Write performance.
Whole-file JSON: each turn serializes the full session (potentially several MB) and atomically writes. As turns accumulate, every write is an IO spike.
JSONL appends one event instead of reserializing the entire history. Syscall count and latency depend on buffering and durability settings; benchmark throughput and power-loss recovery on the target filesystem.
3. Streaming consumption.
Codex’s TUI shows “what the agent is doing” in real time. JSONL can be tail -f-style streamed line by line. Whole-file JSON cannot; reading mid-file parse fails.
JSONL trade-offs
- No “edit history” capability. Append-only: written rows cannot be changed. Codex’s fix: append a correction event; consumers merge.
- File size growth. Long sessions produce big files. Codex archives older sessions to
archived_sessions/. - Schema evolution. Each row’s shape may shift across versions. Codex uses
discriminator: "type"plus per-type deserialization so older rows still parse.
Comparison across systems
- Codex: JSONL + SessionMeta first line. Most engineered.
- Claude Code: multi-file (rollout / cost / attribution each on their own). Append-only in spirit, split across files.
- OpenClaw: session-id validation only; storage decided upstream.
- Hermes: whole-file JSON in
gateway/session. Sessions are short (tens of turns) and reset daily.
Engineering lesson: append-only logs are the default choice for agent persistence. Same lineage as database WAL, Kafka logs, Git object store.
Source: codex/codex-rs/rollout/src/recorder.rs:80-105 (RolloutRecorder) + metadata.rs:39-65 (SessionMeta).
Follow-up: “Why doesn’t Hermes use JSONL?” Hermes is a chat agent: per-chat sessions are short (tens of turns) and reset daily. Whole-file JSON of tens of KB is fast enough. Plus multi-platform, one file per chat_id would make JSONL inconvenient to manage. The scenarios drive different choices.
Q2 · Architecture: Claude Code’s 4 lifecycle sources (startup / resume / clear / compact): why not 2 or 5?
Four is the right granularity. Each source has different semantics to a degree that matters.
startup · brand-new conversation
- User runs
claudefor the first time; no history. - Hook should: load CLAUDE.md, set cwd, init cost tracker / git state, inject system-prompt sections per plugin config.
- Hook should not: pull archived history (none exists), restore worktree session (user did not opt in).
resume · continue a historical session
- User runs
claude --resumeand picks a session. - Hook should: restore cost state, attribution snapshot, file history, todos, model override, worktree state.
- Hook should not: reset cost tracker (resume means continue, not reset), reload CLAUDE.md (already in history).
clear · user-initiated /clear
- User runs
/clearmid-conversation to wipe context but keep session metadata. - Hook should: clear message history, keep cost tracker (billing should not reset), keep model override (preference unchanged), maybe keep todos.
- Hook should not: delete session files (user may want to resume), reset plugin state (plugins have their own lifecycle).
compact · context-threshold-triggered compression
- System detects context tokens > limit and triggers the compact subagent.
- Hook should: snapshot critical info (avoid loss during compression), pause cost-tracker writes (the compact LLM call is billed separately), update systemPrompt (compaction result becomes the new baseline).
- Hook should not: clear message history (compact summarizes, does not discard), reset model (user did not change).
Why not merge?
Merge to 2 sources (new / restore):
- Put clear in new: but clear should not reload CLAUDE.md (already loaded), plugin state should be retained, cost tracker not reset.
newhooks do not know these nuances. - Put compact in restore: but compact runs while the session is alive; the hook is not restoring state but snapshotting and pausing billing.
Add 5+ sources (fork / convert):
- fork has a new session id but inherits part of history. That is essentially
startupplus initial messages. Reuse startup +initial_messagesparameter; no new source needed. - convert (agent → agent) inherits messages but resets the model. Similar situation.
Claude Code arrived at 4 by trial: minimum sufficient granularity. Each source has clear “should / should not” semantics.
Implementation detail
type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact';
async function processSessionStartHooks(source: SessionStartSource) { for (const hook of hooks) { if (hook.appliesTo.includes(source)) { await hook.execute({ source, ... }); } }}Hooks declare appliesTo: ['startup', 'resume'] (skipped on clear / compact). This granularity makes hook authors more precise.
Engineering lesson: lifecycle-event granularity must reflect what hooks should do differently. If two events trigger the same behavior in hooks, merge them. If different, split.
Source: claude-code/src/utils/sessionStart.ts:34-66 (processSessionStartHooks plus the 4 source types).
Follow-up: “Doesn’t Codex have clear / compact sources?” Codex’s lifecycle is RolloutRecorderParams Create / Resume: two. compact is a sub-agent (chapter 10), not a lifecycle event. clear does not exist; Codex encourages opening a new thread (cheap) instead. Different product positioning.
Q3 · Concept: OpenClaw only validates session-id with a regex and leaves storage to the caller. Is that “incomplete” or a “correct boundary”?
A correct boundary. OpenClaw is an agent framework, not an agent product. They care about sessions differently.
Product view (Codex / Claude Code / Hermes)
Users open an agent to finish a concrete task. The product needs to:
- List “sessions from the past 7 days”.
- Resume any session.
- Auto-archive old sessions to bound file count.
- Sync sessions across devices (advanced).
Each requires a persistence layer (JSONL / multi-file / SQLite).
Framework view (OpenClaw)
OpenClaw is for developers who write agents on top. They might:
- Build a Slack bot: store sessions in Slack thread metadata, not locally.
- Build an IDE plugin: store in IDE workspace state.
- Build a SaaS: store in PostgreSQL / Redis.
- Build a CLI: store locally as JSONL (Codex pattern).
OpenClaw’s trade-off
If OpenClaw provided “JSONL session persistence”, two problems:
- Architecture lock-in: OpenClaw + Slack bot would persist to both local JSONL and Slack threads: two sources, two truths.
- Extension drag: every new storage backend (PostgreSQL / Redis / S3 / cloud KV) adds if-else branches in OpenClaw core. A platform framework most cannot afford to hardcode storage.
So OpenClaw chose:
- Provide
session_idvalidation (format compliance). - Provide
session_keyutilities ({agentId}:{sessionId}concatenation). - Provide
transcript-eventsserialization (events → JSON). - Storage / reset / resume left to plugins and upstream callers.
Analogy
A database query layer should not decide “where data lives”. SQLite / PostgreSQL / MySQL swap underneath while the query layer stays consistent. OpenClaw makes session storage a pluggable backend.
Cost
OpenClaw users handle storage themselves:
- Out-of-box is incomplete: a full demo agent must first pick a session backend.
- Ecosystem fragmentation: different plugins may choose different storage, cross-plugin queries are inconvenient.
- Beginner barrier: “where do sessions go?” is the first question; the answer is “you decide.”
OpenClaw mitigates with docs + a few plugin examples (file-based, in-memory).
Decision rubric
Use one question to test “is this abstraction right”: is the business scenario actually varied here?
- Session storage backend: deployments may span local files, Slack, databases, or S3. Leaving the backend unbound preserves adapter choice, while a product with one controlled backend may reasonably fix the implementation.
- Session-id format: UUID has mature tooling, a large random space, and cross-language interoperability, but it is not the only industry format. OpenClaw’s UUID regex is a protocol choice in this snapshot and still needs a storage uniqueness constraint; sortable IDs or database keys may call for ULID or integer IDs.
- Session-key namespace:
agentId + sessionIdfits one-agent, one-session routing. Multi-tenant, multi-platform, or group-chat systems may also need tenant, platform, or chat dimensions. The utility fits only when that naming contract matches.
Use this question as an abstraction-screening step, then verify interoperability, migration, and collision behavior in the target deployment.
Source: openclaw/src/sessions/session-id.ts:1-6 (minimal UUID regex) + session-key-utils.ts.
Follow-up: “But Codex is also extensible, why does Codex bind to JSONL?” Codex is not a framework; it is a product. The Codex team picked JSONL and made all Codex users use it. OpenClaw provides not “a flexible Codex” but “tools so developers can build their own Codex.” Different positioning.
Q4 · Concept: Hermes’s SessionResetPolicy has 4 modes (daily / idle / both / none). Why 4 and not 1?
Each mode maps to a real user group:
daily (reset at a fixed hour) · personal assistant
User: helps things in the morning, picks up at night.
- Pro: each day starts clean; no garbage context accumulation.
- Con: late-night users may suddenly amnesia at the reset hour.
Fits: regular-schedule personal assistants (Notion AI assistant, Telegram bot).
idle (reset after N minutes of inactivity) · project collaboration
User: discusses a project with the agent in a Slack workspace; may go days between messages.
- Pro: activity-based granularity. As long as the user keeps engaging, context stays.
- Con: above the idle threshold, forced reset may annoy users.
Fits: project-collaboration scenarios (Slack agent, Linear assistant).
both (whichever triggers first) · default recommendation
Hermes’s default is both: reset at the daily hour OR after 24h idle.
- Pro: “daily clean slate” stability plus “weekday continuity”.
- Con: more rules to explain.
Fits: choose this only when both reset triggers match the product policy; Hermes’s default is not evidence that it fits most scenarios.
none (never auto-reset) · long-term-memory agent
User: maintains a long-running project with the agent (novel writing, KB organization).
- Pro: context lives forever; the agent really remembers.
- Con: context grows unboundedly; relies on compact (auto triggered) to not blow up.
Fits: long-running projects, creative writing assistants.
Config layering
@dataclassclass SessionResetPolicy: mode: str = "both" at_hour: int = 4 idle_minutes: int = 1440 notify: bool = True notify_exclude_platforms: tuple = ("api_server", "webhook")Note notify_exclude_platforms: send a notification to the user on reset (“agent has reset”). But for api_server / webhook (programs calling in), do not notify (programs do not need it).
Why not let users write reset logic themselves?
If we only provided hooks:
def custom_reset_logic(session): if some_condition: reset(session)Users reinvent daily / idle logic every time. Hermes ships 4 enum modes plus config so most users do not write code.
Per-platform override
reset_by_platform = { Platform.SLACK: SessionResetPolicy(mode="idle", idle_minutes=240), # 4 hours Platform.TELEGRAM: SessionResetPolicy(mode="both"), Platform.LOCAL: SessionResetPolicy(mode="none"), # CLI never resets}A short idle for work Slack channels, longer idle for personal Telegram, never for local CLI. One agent serving multiple platforms benefits from per-platform overrides.
Engineering lesson: reset policy is not a global strategy, it is per-platform / per-context. Provide fixed enum modes plus per-context overrides rather than asking users to write code.
Source: hermes-agent/gateway/config.py:100-145 (SessionResetPolicy).
Follow-up: “How is reset different from compact?” Reset is “conversation zero” (clear message history, keep plugin state); compact is “compression” (keep summarized history, discard raw messages). Reset triggers come from policy + time; compact triggers come from token count. Independent mechanisms living side by side.
Q5 · Concept: Codex separates ThreadId and SessionId. They look duplicated: why not merge?
They express genuinely different concepts.
ThreadId · the logical conversation unit
- A thread can fork (branch from history), resume (continue), archive.
- Threads live across time: a thread started today, resumed tomorrow, archived next week.
- Threads have human meaning: the user says “that refactoring conversation”.
SessionId · one concrete run instance
- A session is process-start → user-interaction → process-exit.
- Sessions are short-lived, paired with the process.
- Sessions have no user-facing meaning: nobody cares about “session 12345”.
Relationship
ThreadId = "thread-refactor-foo" |- Session 1 (Monday 10am-11am) |- Session 2 (Monday 3pm-4pm, resumed from Session 1) |- Session 3 (Tuesday 9am-10am, resumed from Session 2) +- Session 4 (Wednesday, archived)One thread can span many sessions (each resume is a new session).
Why not merge?
Merge to one (call it thread_id):
- A user resuming the same thread twice: would IDs collide? You need a new ID.
- But the thread is the same logical conversation; from the user’s view it should not change name.
Merge to one (call it session_id):
- A fork now has what id? How does it relate to the session it forked from?
- Long-term archive needs stable IDs; session IDs frequently change and hurt cross-time references.
When one stable logical thread spans several runs and each run owns a separate lifecycle, Codex’s split represents those two ownership scopes. A product without resume, fork, or repeated-run semantics may be fine with one ID; lifecycle and reference requirements should decide the split.
Implementation
struct Session { pub(crate) conversation_id: ThreadId, // logical thread pub(crate) session_id: SessionId, // current run}conversation_id is stable; session_id is freshly generated on each startup.
On resume:
fn resume_thread(thread_id: ThreadId) -> Session { let history = load_jsonl_by_thread(thread_id); let session_id = SessionId::new(); Session { conversation_id: thread_id, session_id, }}Comparison across systems
- Codex: ThreadId + SessionId, explicit.
- Claude Code: one
sessionId, with a separateworktreeSessionIdfor worktrees. - OpenClaw: one
sessionId, namespaced via{agentId}:{sessionId}. - Hermes: one
session_id, withplatform + chat_idas a stable composite identifier.
Every system encounters the “short run vs long conversation” distinction; only the naming differs.
Engineering lesson: user-facing IDs (stable) and system-facing IDs (run-scoped) are two things. Conflating them causes: users can’t find their conversations, archive / migration / metrics all go wrong.
Source: ThreadId / SessionId definitions in codex/codex-rs/protocol/src/protocol.rs + Session struct in core/src/session/session.rs:11-37.
Follow-up: “How does fork handle IDs?” Fork creates a new thread from a historical thread. Codex’s RolloutRecorderParams::Create.forked_from_id: Option<ThreadId> records “forked from which thread”. The new thread has its own ThreadId (evolves independently) but remembers its origin for traceability.
Q6 · Practical: You are adding session persistence to your own agent. What does MVP → production look like?
Six phases; advance by prerequisites and acceptance gates, not by a delivery calendar:
Phase 1 · Single JSON file
def save_session(session_id: str, messages: list, meta: dict): path = f"~/.youragent/sessions/{session_id}.json" with open(path, 'w') as f: json.dump({"meta": meta, "messages": messages}, f)Use it first to validate the resume contract. Problems: full write each turn, slow; crash loses all; no streaming.
Phase 2 · switch to JSONL append-only
Prerequisite: the single-file version’s fields and resume semantics are fixed by fixtures.
def append_event(session_id: str, event: dict): path = f"~/.youragent/sessions/{session_id}.jsonl" with open(path, 'a') as f: f.write(json.dumps(event) + "\n")Borrow Codex. First line is SessionMeta, then append each message / event.
Acceptance gate: simulate a truncated tail, restart, and concurrent appends; recovery retains the complete prefix and never silently treats corrupt data as a valid event.
Phase 3 · add SessionMeta + ThreadId/SessionId split
Prerequisite: JSONL events are recoverable and the product distinguishes a logical thread from one run instance.
@dataclassclass SessionMeta: thread_id: str session_id: str cwd: str model: str git_sha: str | None cli_version: str created_at: str forked_from: str | NoneBorrow Codex. Resume by thread_id; startup gets a new session_id.
Acceptance gate: multiple sessions for one thread replay correctly, fork origins remain traceable, and startup never reuses an old session_id.
Phase 4 · SQLite index
Add it only when directory scanning and JSONL-header parsing exceed the product’s measured latency budget.
CREATE TABLE threads ( thread_id TEXT PRIMARY KEY, cwd TEXT, model TEXT, created_at TEXT, last_message_at TEXT, archived BOOLEAN DEFAULT FALSE);Borrow Codex state.db. Backfill on startup: scan JSONL files for new threads.
Acceptance gate: index queries agree with the JSONL source of truth on a fixed session fixture; rebuild, migration, and index corruption either recover or report clearly.
Phase 5 · 4 lifecycle hooks
Prerequisite: plugins or subsystems actually need to intervene at startup, resume, clear, or compact.
class SessionLifecycle: def on_startup(self, session): pass def on_resume(self, session): pass def on_clear(self, session): pass def on_compact(self, session): passBorrow Claude Code 4-source model. Each event triggers its own hooks so plugins can latch on.
Acceptance gate: event order, duplicate delivery, and hook failures have contract tests; hooks cannot change the core resume result.
Phase 6 · reset policy (only for multi-platform agents)
Prerequisite: message origin and platform capabilities are represented in SessionSource, and the product has a defined reset meaning.
@dataclassclass ResetPolicy: mode: Literal["daily", "idle", "both", "none"] = "both" at_hour: int = 4 idle_minutes: int = 1440Borrow Hermes. Add per-platform overrides as needed.
Acceptance gate: boundary fixtures cover daily, idle, both, none, and platform overrides, confirming reset never removes context that still needs recovery.
Key takeaways:
- Prefer JSONL over a single JSON file. Validate tail-corruption and recovery semantics first.
- Define the ThreadId/SessionId split. User and system views must differ.
- Add the SQLite index only when performance demands. Establish a directory-scan baseline, then choose the threshold from observed query latency.
- Lifecycle hooks are a platformization path, so single-product agents can skip them.
- Reset policy is only essential for chat agents; IDE / coding agents do not need it.
Source composition: Codex rollout/src/recorder.rs + metadata.rs + state_db.rs (the basics) → Claude Code sessionStart.ts + sessionRestore.ts (lifecycle) → Hermes gateway/session.py + config.py (multi-platform + reset). A source-code map from MVP to production.
Follow-up: “How would I add cross-device sync?” Swap storage to cloud (S3 / DynamoDB / Firebase); JSONL becomes a stream upload; user login syncs local cache. Big architectural change; design cloud-first up front rather than retrofitting.
Q7 · Architecture: Claude Code’s comment shouts “do not add ANY warmup logic”. Why is this rule so critical?
The startup path is the lifeline of agent UX. Latency drift here is felt by every user.
What the source actually establishes
The cited sessionStart.ts comment establishes a prohibition on adding non-essential startup work; it does not provide the following historical timings. Treat these as benchmark vectors instead:
- Scan ~/.claude on startup to build a quick-resume list.
- Load all plugins on startup to avoid lazy-load latency.
- Run git status on startup to pre-fill context; timing varies with repository and filesystem.
- Fetch latest version on startup; this can usually be background work.
Measure the combined path on the same devices with cold and warm caches; this article has not reproduced a fixed 11-second total.
How did it get there?
Each warmup alone looks reasonable:
- “Scan history to speed up resume”: help users restart faster.
- “Load plugins”: avoid stalls later.
- “git status”: pre-warm context.
- “Fetch version”: security / bug fixes.
Several small warmups can consume a startup budget; compute the regression from CI traces rather than an unverified PR anecdote.
The rule was born
Practical guards include:
- Comment ban: “do not add ANY warmup” in source code, new PRs touching that path must justify.
- Startup time SLA: set a product budget for
claude --versionand flag regressions in CI; derive the value from target-device measurements. - Defer-by-default: all non-essential initialization is lazy-loaded.
How should the budget be set?
Use interaction goals, device distribution, and cold/warm traces; do not treat a generic 200ms threshold as a universal CLI fact.
What should be lazy-loaded?
- History sessions: scan only on
--resume. - Plugins: load when first triggered (each plugin registers its own trigger).
- Git context: fetch when first needed.
- Version check: background async, not blocking startup.
What must happen at startup?
- Parse CLI args.
- Validate API key (otherwise every subsequent call fails).
- Set up logger.
- Register signal handlers.
Measure the total against the target devices, keeping network, plugins, and first authentication as separate variable paths.
Codex learned too
The cited Codex path defers the session list and state.db lookup until needed; the snapshot does not provide a cross-device startup millisecond claim.
Hermes counter-example
Hermes’s startup path also includes enabled platform connections, plugins, and cron initialization; measure which of these run on cold start for the deployment:
- Connect to enabled messaging platforms (which may include OAuth handshakes).
- Load all plugins.
- Initialize cron scheduler.
Server-class apps and CLI agents have different startup constraints: a long-lived server can amortize one cold start, while a CLI exposes it directly to the user. Acceptability depends on the process lifetime.
Engineering lesson: treat CLI startup latency as a product contract. For every new warmup proposal, ask “can it be lazy?”, then use traces from real devices and repositories to justify the budget.
Source: claude-code/src/utils/sessionStart.ts:34 (the comment ban).
Follow-up: “But users expect --resume to be fast.” Query the SQLite index (state.db) when --resume is used, and defer non-essential initialization until then or to a background task. Set the wait budget from a dedicated --resume trace.
Q8 · Practical: A user reports “after resume, the agent feels like a different person”. Systematic triage.
Resume amnesia boils down to “state restoration is incomplete”. Four layers to investigate:
Layer 1 · Message history (most common)
session = load_session(thread_id)print(f"Loaded {len(session.messages)} messages")print(f"Last message: {session.messages[-1]}")If counts are off or truncated, suspect JSONL parsing errors or file corruption. Known Claude Code bugs:
- archived_sessions/ not read, only active directory consulted.
- Cross-version schema incompatibility; the new parser drops old rows.
- Files modified externally (a user edited JSONL to debug).
Layer 2 · System metadata
Even with messages correct, agent behavior may shift because:
print(f"cwd: {session.cwd}")print(f"model: {session.model}")print(f"approval_mode: {session.approval_mode}")print(f"git_sha: {session.git_sha}")Typical bugs:
- cwd not restored: agent in the wrong project directory (files missing).
- Model override not restored: switched from sonnet back to opus (different behavior).
- Approval mode not restored:
--accept-editsbecameinteractive(blocks every edit).
Layer 3 · Subsystem state
Claude Code restores seven categories:
sessionRestore({ cost: ..., # billing state attribution: ..., # user attribution file_history: ..., todos: ..., model_override: ..., worktree_state: ..., system_prompt: ..., # context injection})The easiest to miss is system_prompt sections. Claude Code builds the system prompt from several sections (CLAUDE.md + plugin injections + tool descriptions + user overrides).
Restoring only part of it means the agent does not know about certain tools or project conventions.
Layer 4 · Context-window management
If messages were compacted before, on resume:
- Compact summary may not have been preserved: the agent does not know the prior history.
- Original messages plus a compact summary both retained: duplicate context (context bloat).
Typical fix workflow
- Reproduce with the user; record thread_id.
- Inspect the JSONL file; confirm line count + SessionMeta integrity.
- Add debug logs at every step of sessionRestore: “about to restore cost”, “about to restore attribution”, etc.
- After resume, dump actual session state and compare to SessionMeta expectations.
- Identify which subsystem failed; add a regression test.
Prevention
- End-to-end resume tests: run fixture sessions through resume each release; assert 100 invariants.
- Schema versioning: SessionMeta carries
schema_version; older versions go through a compatibility path. - Resume metrics: track resume success rate; users who
/clearwithin N minutes of resuming (a signal of dissatisfaction).
Comparison across systems
- Codex: resume is relatively stable (JSONL line-by-line replay; state.db is index, not content).
- Claude Code: resume is complex (7 subsystems, order-sensitive).
- OpenClaw: resume semantics are caller-owned.
- Hermes: chat sessions have less state (message history + SessionContext); fewer failure modes.
Engineering lesson: resume is not “replay messages”, it is “rehydrate complete session state”. As many subsystems as you have, that many things need restoring. Each new subsystem updates the resume path.
Source: claude-code/src/utils/sessionRestore.ts:1-58 (import list = the seven things to restore).
Follow-up: “Fallback strategy when resume fails?” Tiered: (1) bad message → skip; (2) cwd missing → ask user to pick a new cwd; (3) invalid model → fallback to default; (4) entire session corrupt → ask user “start fresh or export the old messages?” and let them decide.
Q9 · Engineering: Hermes’s _PII_SAFE_PLATFORMS lists 4 platforms safe for PII redaction. How is the list maintained?
The list is not guessed; it is derived from two constraints:
Constraint 1 · Platform mention syntax
How different platforms mention users:
- WhatsApp: natural language @name (no internal ID needed)
- Signal: phone number / UUID (user-level ID)
- Telegram: natural language + @username (no internal ID needed)
- BlueBubbles: phone number (human readable)
- Discord:
<@user_id>(requires the numeric internal ID) - Slack:
<@U12345678>(requires Slack member ID)
If mention syntax requires the internal ID, the LLM must see the raw user_id to produce a valid mention. Redacting it = mention fails.
So:
- Can redact: WhatsApp / Signal / Telegram / BlueBubbles → in
_PII_SAFE_PLATFORMS. - Cannot redact: Discord / Slack → not in the set.
Constraint 2 · Routing vs LLM input
Hermes keeps the raw IDs in SessionSource for routing; the LLM sees a redacted version:
session_source = SessionSource( platform=Platform.TELEGRAM, chat_id="123456789", user_id="987654321", user_name="Alice",)
# Routing uses raw IDsroute_response(session_source.chat_id, session_source.user_id, response)
# LLM input is redactedprompt = build_session_context_prompt(session_source, redact_pii=True)# prompt contains "hash_user_001" instead of the real user_idThe LLM does not know the real user_id; Hermes does. When the LLM replies “@hash_user_001”, Hermes maps it back internally before sending.
Update rules
- New platform supported: investigate mention syntax. If it uses internal IDs, add to “cannot redact”; if natural language, add to
_PII_SAFE_PLATFORMS. - Platform protocol change: rare. But if Telegram 5.0 starts requiring user_id mentions, remove it from the safe list.
- Legal / compliance shifts: if GDPR / CCPA requires redacting all user data sent to LLMs, redaction becomes mandatory (mention failures notwithstanding).
Why not just redact everywhere?
Mentions are a core “agent talks to you” UX signal:
- Slack channel: agent @-pings you, you see it is yours.
- Discord channel: agent references you, you see it.
If the LLM cannot see real IDs, it cannot produce a mention, agent replies become “ordinary messages”, UX takes a noticeable hit.
So Hermes:
- Defaults
redact_pii=False(no redaction, preserve functionality). - When users opt in via
redact_pii=True, only PII_SAFE_PLATFORMS take effect. - On Discord / Slack, redaction silently falls back to no-redact + audit log (so the user knows).
Industry comparison
OpenAI ChatGPT enterprise:
- All user inputs redacted by default (compliance).
- Cross-chat hashes are inconsistent for the same user → agent cannot remember the user across chats.
- Product capability suffers but compliance wins.
Hermes is a personal / private agent; UX wins. ChatGPT is enterprise; compliance wins. Different optimization targets.
Engineering lesson: safety decisions must trace back to concrete product / platform / legal needs. “Safety for safety’s sake” sacrifices product capability. Document the trade-off (“Discord cannot redact because mentions need raw IDs”) so maintainers understand why.
Source: hermes-agent/gateway/session.py:176-209 (_PII_SAFE_PLATFORMS plus the comment explaining Discord).
Follow-up: “If users do not care about mention failures and want everything redacted?” Config flag force_redact_all_platforms=True. Hermes does not advertise this (UX impact) but enterprise / regulated deployments can opt in.
Q10 · Open-ended: Design a “universal session framework” combining selected patterns by constraint. Provide a minimum API + implementation outline.
Layered, opt-in.
Layer 1 · Core IDs (required)
type ThreadId = string;type SessionId = string;const SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;function newThread(): ThreadId { return crypto.randomUUID(); }function newSession(): SessionId { return crypto.randomUUID(); }function isValidSessionId(id: string): boolean { return SESSION_ID_RE.test(id); }Borrow OpenClaw + Codex.
Layer 2 · SessionMeta (required)
interface SessionMeta { thread_id: ThreadId; session_id: SessionId; forked_from?: ThreadId; cwd: string; model: string; git_sha?: string; cli_version: string; created_at: string; agent_role?: string;}Borrow Codex. First line of JSONL.
Layer 3 · JSONL Rollout (recommended)
interface RolloutRecorder { appendEvent(event: RolloutItem): Promise<void>; flush(): Promise<void>; close(): Promise<void>;}
type RolloutItem = | { type: 'session_meta'; meta: SessionMeta } | { type: 'response_item'; item: ResponseItem } | { type: 'turn_context'; ctx: TurnContext } | { type: 'compacted'; summary: string } | { type: 'event_msg'; event: EventMsg };Borrow Codex 5-variant rollout items.
Layer 4 · SQLite index (production-recommended)
interface SessionIndex { saveThread(meta: SessionMeta): Promise<void>; listThreads(filter: ThreadFilter): Promise<ThreadSummary[]>; findThread(id: ThreadId): Promise<ThreadSummary | null>; archiveThread(id: ThreadId): Promise<void>;}Borrow Codex state.db.
Layer 5 · Lifecycle hooks (recommended)
type SessionSource = 'startup' | 'resume' | 'clear' | 'compact';
interface SessionLifecycleHook { appliesTo: SessionSource[]; execute(source: SessionSource, session: Session): Promise<void>;}Borrow Claude Code 4-source model.
Layer 6 · SessionRestore (recommended)
interface RestoreableSubsystem<T> { name: string; snapshot(session: Session): T; restore(state: T, session: Session): Promise<void>;}Borrow Claude Code multi-subsystem restore.
Layer 7 · Multi-platform SessionSource (optional · chat agents)
interface SessionSource { platform: 'cli' | 'slack' | 'telegram' | 'discord' | ...; chat_id: string; chat_type: 'dm' | 'group' | 'channel'; user_id?: string;}
function buildSessionContextPrompt(source: SessionSource, opts: { redact_pii?: boolean } = {}): string { // inject system-prompt with where messages come from and which platforms are connected}Borrow Hermes SessionSource + PII redaction.
Layer 8 · Reset policy (optional · chat agents)
interface ResetPolicy { mode: 'daily' | 'idle' | 'both' | 'none'; at_hour?: number; idle_minutes?: number;}Borrow Hermes 4-mode reset + per-platform overrides.
Final API
import { SessionManager } from '@your-org/session';
const sm = new SessionManager({ storage: new FileSystemRollout('~/.myagent'), index: new SqliteSessionIndex('~/.myagent/state.db'), resetPolicy: { mode: 'both', at_hour: 4, idle_minutes: 1440 },});
const session = await sm.startSession({ cwd: '/foo', model: 'opus' });const resumed = await sm.resume(threadId);sm.lifecycle.register({ appliesTo: ['startup', 'resume'], execute: async (source, session) => { /* per-source behavior */ },});vs four systems:
- Codex: Layers 1-4.
- Claude Code: Layers 1-6.
- OpenClaw: Layer 1 only.
- Hermes: Layers 1, 2, 7, 8.
Evaluate by scope
- Layers 1-3 · identity and persistence: fix Thread/Session, SessionMeta, and JSONL event contracts. Gate: truncated-tail, restart, fork, and duplicate-ID fixtures produce deterministic results.
- Layers 4-6 · indexing and lifecycle: add SQLite and lifecycle hooks only when query budgets or plugin coordination require them. Prerequisite: a directory-scan baseline and restore order are documented; gate: the index rebuilds from JSONL and hook order/failure semantics have contract tests.
- Layers 7-8 · multi-platform reset/redaction: add only when a chat agent routes by source. Prerequisite: platform capabilities and PII rules are explicit; gate: each reset mode, platform override, and redaction fixture is replayable.
Key decisions
- JSONL is the default.
- Thread/Session separated.
- SQLite index only at scale.
- Lifecycle hooks for platformization.
- Multi-platform / reset only when chat-based.
Follow-up: “How to add cross-device sync?” Layer 9: cloud sync. Swap RolloutRecorder to cloud storage (S3 / GCS / Azure), swap index to cloud DB. Big architectural change; design cloud-first from the start.
Source composition: Codex rollout/ + core/session/ (basics) → Claude Code utils/sessionStart.ts + utils/sessionRestore.ts (lifecycle) → OpenClaw sessions/session-id.ts (minimal validation) → Hermes gateway/session.py + gateway/config.py (multi-platform + reset). Stitch the four together = session framework v0.1.