Session management: SessionManager
Responsibilities
SessionManager is the gatekeeper of OpenClaw agent transcripts. Every user message, assistant reply, tool call, tool result, thinking-level change, model switch, compaction summary, and branch marker ultimately lands in the JSONL file maintained by SessionManager. It also provides query interfaces: buildSessionContext() for the upper layer to assemble LLM context, getTree() for the UI to render branch structures, getBranch() for branch switching, and appendCompaction() to record compaction boundaries.
SessionManager doesn't call models or execute tools — it only ensures that every artifact of a session is safely persisted to disk "in order, by tree structure, with persistence semantics". AgentSession (agent-session.ts:334) is the higher-level semantic wrapper that strings SessionManager + Agent + tool registry + hooks into a complete agent runtime object.
Design motivation
Why a separate SessionManager rather than letting the agent append to a file directly? Because OpenClaw's session semantics aren't a "linear log" — they're a "tree with branches". Inside a session file, each entry has an id and parentId, forming a forest: when branch(branchFromId) creates a branch, the new leaf starts from the specified node and the old branch stays for traceability. With plain append, the branch structure would be lost.
Why wrap AgentSession on top of SessionManager? Because tool registry (toolRegistry), model registry (sessionModelRegistry), system prompt assembly, and compaction control are different concerns from transcript persistence. SessionManager focuses on storage; AgentSession focuses on the mutable state needed to "run an agent". This layering lets SessionManager be tested independently and keeps the transcript file format from being polluted by runtime logic.
Another motivation is concurrency safety. A session may have multiple writers: the agent main loop appending assistant messages, tool execution appending tool results, compaction rewriting the prefix, external steering inserting context messages. SessionManager serializes writes at the attempt layer through sessionFileSnapshot caching + OwnedSessionTranscriptWriteLock, so the file can't be corrupted by interleaved writes. If a file is detected to be externally modified (snapshot mismatch), the warm cache is invalidated and the whole transcript is re-parsed — slow, but correct.
Key files
session-manager.ts— 3000+-line SessionManager class plus entry type definitions.session-manager.ts CURRENT_SESSION_VERSION:51— version re-export.session-manager.ts SessionEntry:184-216— entry union type + SessionContext structure.session-manager.ts constructor:1439-1487— SessionManager private constructor, initializes all index Maps.session-manager.ts static factories:2894-2934— four static factories:create / open / continueRecent / inMemory.session-manager.ts newSession:1556-1579— creates a new session, writes the header.session-manager.ts persist:2154-2156— single-entry persistence entry point.session-manager.ts syncSnapshotAfterHeaderRewrite:2166-2174— re-sync snapshot after external file rewrite.agent-session.ts AgentSession:334-358— AgentSession class declaration, holds SessionManager + Agent.agent-session.ts toolRegistry:395-398— four Maps for tools / tool definitions / prompt snippets / guidelines.agent-session.ts refreshToolRegistry:2414-2486— tool registry rebuild logic.session-manager-init.ts prepareSessionManagerForRun:47-117— session file normalization before entering the run loop.src/sessions/— semantic layer: session-key resolution, kind classification, lifecycle events, transcript events, etc.
Data flow
SessionManager's entry points are several static factories (session-manager.ts:2894). All construction goes through the private constructor; the factory methods decide how to fill parameters:
static create(cwd: string, sessionDir?: string): SessionManager {
const dir = sessionDir ?? getDefaultSessionDir(cwd);
return new SessionManager(cwd, dir, undefined, true);
}
static open(path: string, sessionDir?: string, cwdOverride?: string): SessionManager {
const loaded = revalidateLoadedSessionFile(path, loadEntriesFromFileWithSnapshot(path));
const header = loaded.entries.find((e) => e.type === "session");
const cwd = cwdOverride ?? header?.cwd ?? process.cwd();
const dir = sessionDir ?? resolve(path, "..");
return new SessionManager(cwd, dir, path, true, loaded);
}
static continueRecent(cwd: string, sessionDir?: string): SessionManager {
const dir = sessionDir ?? getDefaultSessionDir(cwd);
const mostRecent = findMostRecentSession(dir);
if (mostRecent) {
return new SessionManager(cwd, dir, mostRecent, true);
}
return new SessionManager(cwd, dir, undefined, true);
}
static inMemory(cwd: string = process.cwd()): SessionManager {
return new SessionManager(cwd, "", undefined, false);
}create makes a fresh session; open opens a specified file and runs revalidateLoadedSessionFile to prevent stale; continueRecent continues from the most recent session; inMemory skips disk and is used for tests and ephemeral context. Note open runs revalidateLoadedSessionFile one extra time — the comment notes "a single parsed load must not go stale while deriving cwd/session metadata", paying one extra stat for warm open to avoid the file being modified mid-load.
The constructor (session-manager.ts:1462) maintains index Maps that determine SessionManager's memory cost model:
private sessionId = "";
private sessionFile: string | undefined;
private sessionDir: string;
private cwd: string;
private shouldPersist: boolean;
private flushed = false;
private fileEntries: FileEntry[] = [];
private opaqueFileEntries: PreservedOpaqueFileEntry[] = [];
private byId: Map<string, SessionEntry> = new Map();
private opaqueParentsById: Map<string, string | null> = new Map();
private logicalParentsById: Map<string, string | null> = new Map();
private invalidLeafControlIds: Set<string> = new Set();
private labelsById: Map<string, string> = new Map();
private labelTimestampsById: Map<string, string> = new Map();
private leafId: string | null = null;
private appendParentId: string | null = null;
private promptReleasedSideBranchId: string | null | undefined;
private recoveredCorruptHeader = false;
private sessionFileSnapshot: SessionFileSnapshot | undefined;Note the three Maps byId, opaqueParentsById, logicalParentsById — the same entry is laid out linearly in the file, but in memory three indexes are maintained: id→entry, id→opaque parent, id→logical parent. The opaque parent is the file's physical structure; the logical parent may differ because compacted or merged entries can have different logical parents. labelsById and labelTimestampsById are stored separately because labels can be modified and timestamps are needed to determine which is newest.
Creating a new session (session-manager.ts:1556) resets all indexes to initial state:
newSession(options?: NewSessionOptions): string | undefined {
this.recoveredCorruptHeader = false;
this.sessionFileSnapshot = undefined;
this.sessionId = options?.id ?? createSessionId();
const timestamp = new Date().toISOString();
const header: SessionHeader = {
type: "session",
version: CURRENT_SESSION_VERSION,
id: this.sessionId,
timestamp,
cwd: this.cwd,
parentSession: options?.parentSession,
};
this.fileEntries = [header];
this.opaqueFileEntries = [];
this.byId.clear();
this.opaqueParentsById.clear();
this.logicalParentsById.clear();
this.invalidLeafControlIds.clear();
this.labelsById.clear();
this.leafId = null;
this.appendParentId = null;
this.promptReleasedSideBranchId = undefined;
this.flushed = false;Note header is the first fileEntry, carrying parentSession — this is the source of the fork branch chain. If parentSession is provided at session creation, the shared prefix can be pulled from the parent session later.
Each entry is persisted (session-manager.ts:2154) via persist:
persist(entry: SessionEntry, options?: AppendPersistenceOptions): void {
this.persistRecord(entry, options);
}persistRecord maintains the fileEntries array, the byId Map, the leafId pointer, and appends a JSONL line to the disk file. The semantic methods appendMessage / appendCompaction / appendThinkingLevelChange / appendModelChange / appendCustomEntry / appendSessionInfo all build an entry and call persist.
AgentSession adds tool registry on top of SessionManager (agent-session.ts:395):
private toolRegistry: Map<string, AgentTool> = new Map();
private toolDefinitions: Map<string, ToolDefinitionEntry> = new Map();
private toolPromptSnippets: Map<string, string> = new Map();
private toolPromptGuidelines: Map<string, string[]> = new Map();Each Map has its own role: toolRegistry holds the runnable AgentTool instances (with execute); toolDefinitions holds the declarations (entry definitions + sourceInfo); toolPromptSnippets holds the tool's prompt fragments; toolPromptGuidelines holds the tool's prompt guidelines. These Maps aren't stored in SessionManager — they're runtime state, while SessionManager only manages persistence state.
Rebuilding the tool registry (refreshToolRegistry, agent-session.ts:2414) is one of AgentSession's most complex methods. It first collects all candidate tools, applies allowlist filters, then wraps each with execution hooks:
private refreshToolRegistry(options?: {
activeToolNames?: string[];
includeAllExtensionTools?: boolean;
}): void {
const previousRegistryNames = new Set(this.toolRegistry.keys());
const previousActiveToolNames = this.getActiveToolNames();
const allowedToolNames = this.allowedToolNames;
const isDisabledBuiltInToolName = (name: string): boolean =>
this.disableBuiltInTools && this.baseToolDefinitions.has(name);
const isAllowedTool = (name: string): boolean =>
!isDisabledBuiltInToolName(name) && (!allowedToolNames || allowedToolNames.has(name));
const registeredTools = this.currentExtensionRunner.getAllRegisteredTools();
const allCustomTools = [
...registeredTools,
...this.customTools.map((definition) => ({
definition,
sourceInfo: createSyntheticSourceInfo(`<sdk:${definition.name}>`, { source: "sdk" }),
})),
].filter((tool) => isAllowedTool(tool.definition.name));
const definitionRegistry = new Map<string, ToolDefinitionEntry>(
Array.from(this.baseToolDefinitions.entries())
.filter(([name]) => isAllowedTool(name))
.map(([name, definition]) => [
name,
{
definition,
sourceInfo: createSyntheticSourceInfo(`<builtin:${name}>`, { source: "builtin" }),
},
]),
);
for (const tool of allCustomTools) {
definitionRegistry.set(tool.definition.name, {
definition: tool.definition,
sourceInfo: tool.sourceInfo,
});
}
this.toolDefinitions = definitionRegistry;
...
const toolRegistry = new Map(wrappedBuiltInTools.map((tool) => [tool.name, tool]));
for (const tool of wrappedExtensionTools) {
toolRegistry.set(tool.name, tool);
}
this.toolRegistry = toolRegistry;Note the three-layer isAllowedTool filter: disableBuiltInTools + allowedToolNames + inline filter. disableBuiltInTools is an agent-level "I only use extension tools" switch; allowedToolNames is a more fine-grained per-tool allowlist. The two layers are independent, allowing combinations like "allow all builtins but disable a specific extension".
After rebuild, nextActiveToolNames (agent-session.ts:2488) preserves the previous active tool set, adds newly-appearing tools, and filters out tools rejected by the allowlist. This handles the incremental scenario "an extension plugin newly installed a tool; the old active list is still valid with one more entry".
Before entering the run loop there's one more normalization (session-manager-init.ts:47):
export async function prepareSessionManagerForRun(params: {
sessionManager: unknown;
sessionFile: string;
hadSessionFile: boolean;
sessionId: string;
cwd: string;
}): Promise<void> {
const sm = params.sessionManager as {
sessionId: string;
cwd: string;
flushed: boolean;
fileEntries: Array<SessionHeaderEntry | SessionMessageEntry | { type: string }>;
...
};
const header = sm.fileEntries.find((e): e is SessionHeaderEntry => e.type === "session");
const hasAssistant = sm.fileEntries.some(
(e) => e.type === "message" && (e as SessionMessageEntry).message?.role === "assistant",
);
if (!params.hadSessionFile && header) {
header.id = params.sessionId;
header.cwd = params.cwd;
sm.sessionId = params.sessionId;
sm.cwd = params.cwd;
return;
}
if (params.hadSessionFile && header && !hasAssistant) {
const preservesForkedBranch =
typeof header.parentSession === "string" && header.parentSession.length > 0;
if (sm.wasRecoveredFromCorruptHeader?.() || preservesForkedBranch) {
...
return;
}
// Reset file so the first assistant flush includes header+user+assistant in order.
await assertExistingHeaderIsReadable(params.sessionFile);
await fs.writeFile(params.sessionFile, "", "utf-8");
invalidateSessionFileRepairCache(params.sessionFile);
header.id = params.sessionId;
...
sm.fileEntries = [header];
sm.flushed = false;
return;
}This handles three cases: a fresh file (only modify header id/cwd), an old file with header but no assistant (rewrite directly so the first flush writes header+user+assistant in order), and a fork branch or corrupt recovery (preserve the original tree). Note await fs.writeFile(params.sessionFile, "", "utf-8") directly empties the file — this is the fallback for the "user pre-created an empty session file" scenario, preventing the first assistant flush from writing the header after the user and producing wrong order.
src/sessions/ is the higher-level semantic layer: it doesn't touch files directly but abstracts cross-component semantics like session key, session kind, session lifecycle events, and transcript events. SessionManager manages "how things persist"; src/sessions/ manages "how sessions are categorized and identified across channels".
Boundaries and failures
- construction is private:
SessionManager's constructor isprivate(session-manager.ts:1462); external code can only enter through the four static factoriescreate / open / continueRecent / inMemory. This forces every construction to go through snapshot loading and cwd validation, preventing external code fromnew-ing an inconsistent instance directly. - corrupt header recovery: the
recoveredCorruptHeaderflag (session-manager.ts:1459) is set when header parsing fails. WhenprepareSessionManagerForRunsees it, it takes the "preserve tree" branch instead of emptying the file — this avoids re-emptying a corrupt-but-recoverable session. - warm cache invalidates eagerly:
syncSnapshotAfterHeaderRewrite(session-manager.ts:2166) is called after external file rewrites. If the file's actual content doesn't match the snapshot,rememberAppendedSessionEntrytakes the snapshot-mismatch branch, drops the warm cache, and forces a re-parse. Slow, but correct. - flush order hard constraint:
prepareSessionManagerForRunempties the file directly in the "header but no assistant" scenario (session-manager-init.ts:103-117), because OpenClaw requires the order in the file at the first assistant flush to be header → user → assistant. If the user pre-created the file with a user message but nothing after the header, flushing assistant directly would make the order user → assistant → header. - fork branch preserves the original tree: the
preservesForkedBranchbranch (session-manager-init.ts:82) skips emptying for sessions with non-emptyheader.parentSession, because a forked branch may intentionally keep a user-only or empty subtree as its starting point; emptying would lose fork semantics. - toolRegistry rebuild isn't persisted:
refreshToolRegistryonly updates the in-memorytoolRegistry(agent-session.ts:2486) and doesn't write to the file. Tool registration is runtime state; after session restart it's rebuilt from agent config — the transcript only records tool_call inputs and outputs, not tool definitions. - active tool set incremental preservation: the
nextActiveToolNamesalgorithm (agent-session.ts:2488) preserves the previous active tool set when the allowlist changes or extension tools are added/removed, only appending newly-appearing tools. This avoids the "active tool set cleared after reinstalling a plugin" breakage. - sessionFileSnapshot is a cache, not the source of truth: all write paths assume the snapshot may be stale, so
rememberWrittenSessionEntriesreturns averifiedWriteflag (session-manager.ts:2170) distinguishing "verified" from "optimistic cache". This is the fallback for concurrent external writes — the file may be modified by another process, and SessionManager can't assume it's the only writer.
Summary
SessionManager is OpenClaw's session persistence layer: it manages transcript file appends, indexing, branching, compaction boundaries, and labels. AgentSession adds runtime state on top — tool registry, model registry, system prompt, compaction controller. The two layers have clear responsibilities: SessionManager stores "what happened"; AgentSession stores "how to run now".
How the agent main loop uses SessionManager to trigger attempts is in Agent main loop: embedded-runner; how tool definitions are assembled into toolRegistry from baseToolDefinitions + extension tools is in Tool system; deeper data flows for transcript under compaction and branching are in Context Engine.
Official references: Session docs · README.