Skip to content

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

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 invariantAcceptance 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”
Four session models: JSONL rollout, lifecycle hooks, minimal identity, and platform-aware routing
One recovery contract; four choices for where truth lives: event files, subsystem state, upstream storage, or platform routing.
Recovery decision CodexClaude CodeOpenClawHermes
Source of truth Append-only JSONL rollout; SQLite is an indexMessages, cost, files, todos, worktree, and other subsystem stateSession identity and transcript contract; storage is upstreamSessionContext persisted by platform + chat_id
Identity model ThreadId vs SessionId, with fork and archiveSession plus worktree, task, and sub-agent dimensionsAgent scope key plus session idSessionSource plus platform, chat_id, and reset policy
Recovery trigger Create / Resume recorder entry pointsstartup / resume / clear / compact lifecycle sourcesCaller loads and switches by ididle / daily / both / none reset
Primary risk Event format and index migrations divergeCross-subsystem restore order becomes complexIdentity exists but recovery semantics remain undefinedPlatform capabilities, PII, and reset rules keep diverging
Compare recovery contracts before storage formats

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 hooks

These 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)
@dataclass
class 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
@dataclass
class 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 system
that 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.

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.

Four session models plotted on storage depth × lifecycle granularity
OpenClaw's minimal id sits bottom-left; Codex's JSONL+SQLite sits middle-right; Claude Code's 22 utilities + 4 hooks sits top-right; Hermes's multi-platform routing sits center-upper.

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 constraintStart withCost or boundary
History must support list, resume, fork, and archiveCodex JSONL, SQLite index, and separate Thread/Session IDsFile format and index migrations must stay consistent
Session state must coordinate with IDE subsystems and hooksClaude Code source hooks and restore layersRestore order affects behavior
A framework should define identity but not storageOpenClaw session IDs, scope keys, and transcript contractStorage and resume semantics move upstream
Multi-channel chat needs source-aware reset and redactionHermes SessionSource, reset policy, and PII listEach platform has different capabilities

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
Four session models laid out side by side
Codex persists with JSONL+SQLite; Claude Code splits into 4 hooks + 22 utilities; OpenClaw uses a minimal id; Hermes routes across 6+ platforms with 4 reset modes.

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:

  1. Corrupt tail: truncate the final JSONL record; retain earlier complete events and mark the tail for review.
  2. Missing index: delete the SQLite index; rebuild it from the event source of truth instead of losing the thread.
  3. Environment drift: switch repository or Git branch; block silent continuation and show the mismatch.
  4. 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.

Open the exercises and ten review questions
  1. 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.
  2. 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.
  3. Medium: implement processSessionLifecycle(source) where source is in {startup, resume, clear, compact}. Each source calls a different hook set. Verify: clear resets the cost tracker, resume does not.
  4. Hard: implement SessionResetPolicy with all four modes (daily / idle / both / none). idle_minutes compares against last_message_at; daily checks whether local time has crossed at_hour. On reset, fire a notification (excluding api_server / webhook).
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

  1. No “edit history” capability. Append-only: written rows cannot be changed. Codex’s fix: append a correction event; consumers merge.
  2. File size growth. Long sessions produce big files. Codex archives older sessions to archived_sessions/.
  3. 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 claude for 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 --resume and 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 /clear mid-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. new hooks 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 startup plus initial messages. Reuse startup + initial_messages parameter; 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:

  1. List “sessions from the past 7 days”.
  2. Resume any session.
  3. Auto-archive old sessions to bound file count.
  4. 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:

  1. Architecture lock-in: OpenClaw + Slack bot would persist to both local JSONL and Slack threads: two sources, two truths.
  2. 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_id validation (format compliance).
  • Provide session_key utilities ({agentId}:{sessionId} concatenation).
  • Provide transcript-events serialization (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:

  1. Out-of-box is incomplete: a full demo agent must first pick a session backend.
  2. Ecosystem fragmentation: different plugins may choose different storage, cross-plugin queries are inconvenient.
  3. 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 + sessionId fits 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

@dataclass
class 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 separate worktreeSessionId for worktrees.
  • OpenClaw: one sessionId, namespaced via {agentId}:{sessionId}.
  • Hermes: one session_id, with platform + chat_id as 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.

@dataclass
class SessionMeta:
thread_id: str
session_id: str
cwd: str
model: str
git_sha: str | None
cli_version: str
created_at: str
forked_from: str | None

Borrow 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): pass

Borrow 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.

@dataclass
class ResetPolicy:
mode: Literal["daily", "idle", "both", "none"] = "both"
at_hour: int = 4
idle_minutes: int = 1440

Borrow 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:

  1. Prefer JSONL over a single JSON file. Validate tail-corruption and recovery semantics first.
  2. Define the ThreadId/SessionId split. User and system views must differ.
  3. Add the SQLite index only when performance demands. Establish a directory-scan baseline, then choose the threshold from observed query latency.
  4. Lifecycle hooks are a platformization path, so single-product agents can skip them.
  5. 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:

  1. Scan ~/.claude on startup to build a quick-resume list.
  2. Load all plugins on startup to avoid lazy-load latency.
  3. Run git status on startup to pre-fill context; timing varies with repository and filesystem.
  4. 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:

  1. Comment ban: “do not add ANY warmup” in source code, new PRs touching that path must justify.
  2. Startup time SLA: set a product budget for claude --version and flag regressions in CI; derive the value from target-device measurements.
  3. 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-edits became interactive (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

  1. Reproduce with the user; record thread_id.
  2. Inspect the JSONL file; confirm line count + SessionMeta integrity.
  3. Add debug logs at every step of sessionRestore: “about to restore cost”, “about to restore attribution”, etc.
  4. After resume, dump actual session state and compare to SessionMeta expectations.
  5. 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 /clear within 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 IDs
route_response(session_source.chat_id, session_source.user_id, response)
# LLM input is redacted
prompt = build_session_context_prompt(session_source, redact_pii=True)
# prompt contains "hash_user_001" instead of the real user_id

The 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

  1. JSONL is the default.
  2. Thread/Session separated.
  3. SQLite index only at scale.
  4. Lifecycle hooks for platformization.
  5. 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.