import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; import type { ImageContent, Message, MessageAttribution, ProviderPayload, ServiceTier, TextContent, Usage, } from "@oh-my-pi/pi-ai"; import { getTerminalId } from "@oh-my-pi/pi-tui"; import { getBlobsDir, getAgentDir as getDefaultAgentDir, getProjectDir, getSessionsDir, getTerminalSessionsDir, isEnoent, logger, parseJsonlLenient, pathIsWithin, resolveEquivalentPath, Snowflake, toError, } from "@oh-my-pi/pi-utils"; import { ArtifactManager } from "./artifacts"; import { type BlobPutResult, BlobStore, externalizeImageData, externalizeImageDataSync, externalizeImageDataUrl, externalizeImageDataUrlSync, isBlobRef, isImageDataUrl, resolveImageData, resolveImageDataUrl, } from "./blob-store"; import { type BashExecutionMessage, type CustomMessage, createBranchSummaryMessage, createCompactionSummaryMessage, createCustomMessage, type FileMentionMessage, type HookMessage, type PythonExecutionMessage, sanitizeRehydratedOpenAIResponsesAssistantMessage, stripInternalDetailsFields, } from "./messages"; import type { SessionStorage, SessionStorageWriter } from "./session-storage"; import { FileSessionStorage, MemorySessionStorage } from "./session-storage"; export const CURRENT_SESSION_VERSION = 3; export interface SessionHeader { type: "session"; version?: number; // v1 sessions don't have this id: string; title?: string; // Auto-generated title from first message titleSource?: "auto" | "user"; timestamp: string; cwd: string; parentSession?: string; } export interface NewSessionOptions { parentSession?: string; /** Skip flushing the current session and delete it instead of saving. */ drop?: boolean; } export interface SessionEntryBase { type: string; id: string; parentId: string | null; timestamp: string; } export interface SessionMessageEntry extends SessionEntryBase { type: "message"; message: AgentMessage; } export interface ThinkingLevelChangeEntry extends SessionEntryBase { type: "thinking_level_change"; thinkingLevel?: string | null; } export interface ModelChangeEntry extends SessionEntryBase { type: "model_change"; /** Model in "provider/modelId" format */ model: string; /** Role: "default", "smol", "slow", etc. Undefined treated as "default" */ role?: string; } export interface ServiceTierChangeEntry extends SessionEntryBase { type: "service_tier_change"; serviceTier: ServiceTier | null; } export interface CompactionEntry extends SessionEntryBase { type: "compaction"; summary: string; shortSummary?: string; firstKeptEntryId: string; tokensBefore: number; /** Extension-specific data (e.g., ArtifactIndex, version markers for structured compaction) */ details?: T; /** Hook-provided data to persist across compaction */ preserveData?: Record; /** True if generated by an extension, undefined/false if pi-generated (backward compatible) */ fromExtension?: boolean; } export interface BranchSummaryEntry extends SessionEntryBase { type: "branch_summary"; fromId: string; summary: string; /** Extension-specific data (not sent to LLM) */ details?: T; /** True if generated by an extension, false if pi-generated */ fromExtension?: boolean; } /** * Custom entry for extensions to store extension-specific data in the session. * Use customType to identify your extension's entries. * * Purpose: Persist extension state across session reloads. On reload, extensions can * scan entries for their customType and reconstruct internal state. * * Does NOT participate in LLM context (ignored by buildSessionContext). * For injecting content into context, see CustomMessageEntry. */ export interface CustomEntry extends SessionEntryBase { type: "custom"; customType: string; data?: T; } /** Label entry for user-defined bookmarks/markers on entries. */ export interface LabelEntry extends SessionEntryBase { type: "label"; targetId: string; label: string | undefined; } /** TTSR injection entry - tracks which time-traveling rules have been injected this session. */ export interface TtsrInjectionEntry extends SessionEntryBase { type: "ttsr_injection"; /** Names of rules that were injected */ injectedRules: string[]; } /** Persisted MCP discovery selection state for a session branch. */ export interface MCPToolSelectionEntry extends SessionEntryBase { type: "mcp_tool_selection"; /** MCP tool names selected for visibility in discovery mode. */ selectedToolNames: string[]; } /** Session init entry - captures initial context for subagent sessions (debugging/replay). */ export interface SessionInitEntry extends SessionEntryBase { type: "session_init"; /** Full system prompt sent to the model */ systemPrompt: string; /** Initial task/user message */ task: string; /** Tools available to the agent */ tools: string[]; /** Output schema if structured output was requested */ outputSchema?: unknown; } /** Mode change entry - tracks agent mode transitions (e.g. plan mode). */ export interface ModeChangeEntry extends SessionEntryBase { type: "mode_change"; /** Current mode name, or "none" when exiting a mode */ mode: string; /** Optional mode-specific data (e.g. plan file path) */ data?: Record; } /** * Custom message entry for extensions to inject messages into LLM context. * Use customType to identify your extension's entries. * * Unlike CustomEntry, this DOES participate in LLM context. * The content participates in LLM context through convertToLlm(). * Use details for extension-specific metadata (not sent to LLM). * * display controls TUI rendering: * - false: hidden entirely * - true: rendered with distinct styling (different from user messages) */ export interface CustomMessageEntry extends SessionEntryBase { type: "custom_message"; customType: string; content: string | (TextContent | ImageContent)[]; details?: T; display: boolean; /** Who initiated this message for billing/attribution semantics. */ attribution?: MessageAttribution; } /** Session entry - has id/parentId for tree structure (returned by "read" methods in SessionManager) */ export type SessionEntry = | SessionMessageEntry | ThinkingLevelChangeEntry | ModelChangeEntry | ServiceTierChangeEntry | CompactionEntry | BranchSummaryEntry | CustomEntry | CustomMessageEntry | LabelEntry | TtsrInjectionEntry | MCPToolSelectionEntry | SessionInitEntry | ModeChangeEntry; /** Raw file entry (includes header) */ export type FileEntry = SessionHeader | SessionEntry; /** Tree node for getTree() - defensive copy of session structure */ export interface SessionTreeNode { entry: SessionEntry; children: SessionTreeNode[]; /** Resolved label for this entry, if any */ label?: string; } export interface SessionContext { messages: AgentMessage[]; thinkingLevel?: string; serviceTier?: ServiceTier; /** Model roles: { default: "provider/modelId", small: "provider/modelId", ... } */ models: Record; /** Names of TTSR rules that have been injected this session */ injectedTtsrRules: string[]; /** MCP tool names selected through discovery for this session branch. */ selectedMCPToolNames: string[]; /** Whether this branch contains an explicit persisted MCP selection entry. */ hasPersistedMCPToolSelection: boolean; /** Active mode (e.g. "plan") or "none" if no special mode is active */ mode: string; /** Mode-specific data from the last mode_change entry */ modeData?: Record; } export interface SessionInfo { path: string; id: string; /** Working directory where the session was started. Empty string for old sessions. */ cwd: string; title?: string; /** Path to the parent session (if this session was forked). */ parentSessionPath?: string; created: Date; modified: Date; messageCount: number; /** File size in bytes on disk; used for compact list rendering. */ size: number; firstMessage: string; allMessagesText: string; } export type ReadonlySessionManager = Pick< SessionManager, | "getCwd" | "getSessionDir" | "getSessionId" | "getSessionFile" | "getSessionName" | "getArtifactsDir" | "getArtifactManager" | "allocateArtifactPath" | "saveArtifact" | "getArtifactPath" | "getLeafId" | "getLeafEntry" | "getEntry" | "getLabel" | "getBranch" | "getHeader" | "getEntries" | "getTree" | "getUsageStatistics" | "putBlob" >; function createSessionId(): string { return Bun.randomUUIDv7(); } /** Generate a unique short ID (8 hex chars, collision-checked) */ function generateId(byId: { has(id: string): boolean }): string { for (let i = 0; i < 100; i++) { const id = crypto.randomUUID().slice(-8); if (!byId.has(id)) return id; } return Snowflake.next(); // fallback to full snowflake id } /** Migrate v1 → v2: add id/parentId tree structure. Mutates in place. */ function migrateV1ToV2(entries: FileEntry[]): void { const ids = new Set(); let prevId: string | null = null; for (const entry of entries) { if (entry.type === "session") { entry.version = 2; continue; } entry.id = generateId(ids); entry.parentId = prevId; prevId = entry.id; // Convert firstKeptEntryIndex to firstKeptEntryId for compaction if (entry.type === "compaction") { const comp = entry as CompactionEntry & { firstKeptEntryIndex?: number }; if (typeof comp.firstKeptEntryIndex === "number") { const targetEntry = entries[comp.firstKeptEntryIndex]; if (targetEntry && targetEntry.type !== "session") { comp.firstKeptEntryId = targetEntry.id; } delete comp.firstKeptEntryIndex; } } } } /** Migrate v2 → v3: rename hookMessage role to custom. Mutates in place. */ function migrateV2ToV3(entries: FileEntry[]): void { for (const entry of entries) { if (entry.type === "session") { entry.version = 3; continue; } if (entry.type === "message") { const msg = entry.message as { role?: string }; if (msg.role === "hookMessage") { (entry.message as { role: string }).role = "custom"; } } } } /** * Run all necessary migrations to bring entries to current version. * Mutates entries in place. Returns true if any migration was applied. */ function migrateToCurrentVersion(entries: FileEntry[]): boolean { const header = entries.find(e => e.type === "session") as SessionHeader | undefined; const version = header?.version ?? 1; if (version >= CURRENT_SESSION_VERSION) return false; if (version < 2) migrateV1ToV2(entries); if (version < 3) migrateV2ToV3(entries); return true; } /** Exported for testing */ export function migrateSessionEntries(entries: FileEntry[]): void { migrateToCurrentVersion(entries); } const migratedSessionRoots = new Set(); /** * Merge or rename a legacy session directory into its canonical target. * Best effort: callers decide whether migration failures should surface. */ function migrateSessionDirPath(oldPath: string, newPath: string): void { const existing = fs.statSync(newPath, { throwIfNoEntry: false }); if (existing?.isDirectory()) { for (const file of fs.readdirSync(oldPath)) { const src = path.join(oldPath, file); const dst = path.join(newPath, file); if (!fs.existsSync(dst)) { fs.renameSync(src, dst); } } fs.rmSync(oldPath, { recursive: true, force: true }); return; } if (existing) { fs.rmSync(newPath, { recursive: true, force: true }); } fs.renameSync(oldPath, newPath); } function encodeLegacyAbsoluteSessionDirName(cwd: string): string { const resolvedCwd = path.resolve(cwd); return `--${resolvedCwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; } function encodeRelativeSessionDirName(prefix: string, root: string, cwd: string): string { const relative = path.relative(root, cwd).replace(/[/\\:]/g, "-"); return relative ? (prefix.endsWith("-") ? `${prefix}${relative}` : `${prefix}-${relative}`) : prefix; } function getDefaultSessionDirName(cwd: string): { encodedDirName: string; resolvedCwd: string } { const resolvedCwd = path.resolve(cwd); const canonicalCwd = resolveEquivalentPath(resolvedCwd); const home = resolveEquivalentPath(os.homedir()); const tempRoot = resolveEquivalentPath(os.tmpdir()); const encodedDirName = pathIsWithin(home, canonicalCwd) ? encodeRelativeSessionDirName("-", home, canonicalCwd) : pathIsWithin(tempRoot, canonicalCwd) ? encodeRelativeSessionDirName("-tmp", tempRoot, canonicalCwd) : encodeLegacyAbsoluteSessionDirName(canonicalCwd); return { encodedDirName, resolvedCwd }; } /** * Migrate old `---*--` session dirs to the new `-*` format. * Runs once per sessions root on first access, best-effort. */ function migrateHomeSessionDirs(sessionsRoot: string): void { if (migratedSessionRoots.has(sessionsRoot)) return; migratedSessionRoots.add(sessionsRoot); const home = os.homedir(); const homeEncoded = home.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-"); const oldPrefix = `--${homeEncoded}-`; const oldExact = `--${homeEncoded}--`; let entries: string[]; try { entries = fs.readdirSync(sessionsRoot); } catch { return; } for (const entry of entries) { let remainder: string; if (entry === oldExact) { remainder = ""; } else if (entry.startsWith(oldPrefix) && entry.endsWith("--")) { remainder = entry.slice(oldPrefix.length, -2); } else { continue; } const newName = remainder ? `-${remainder}` : "-"; const oldPath = path.join(sessionsRoot, entry); const newPath = path.join(sessionsRoot, newName); try { migrateSessionDirPath(oldPath, newPath); } catch { // Best effort } } } function migrateLegacyAbsoluteSessionDir(cwd: string, sessionDir: string, sessionsRoot: string): void { const legacyDir = path.join(sessionsRoot, encodeLegacyAbsoluteSessionDirName(cwd)); if (legacyDir === sessionDir || !fs.existsSync(legacyDir)) return; try { migrateSessionDirPath(legacyDir, sessionDir); } catch { // Best effort } } function resolveManagedSessionRoot(sessionDir: string, cwd: string): string | undefined { const currentDirName = path.basename(sessionDir); const { encodedDirName } = getDefaultSessionDirName(cwd); if (currentDirName !== encodedDirName && currentDirName !== encodeLegacyAbsoluteSessionDirName(cwd)) { return undefined; } return path.dirname(sessionDir); } /** Exported for compaction.test.ts */ export function parseSessionEntries(content: string): FileEntry[] { return parseJsonlLenient(content); } export function getLatestCompactionEntry(entries: SessionEntry[]): CompactionEntry | null { for (let i = entries.length - 1; i >= 0; i--) { if (entries[i].type === "compaction") { return entries[i] as CompactionEntry; } } return null; } /** * Build the session context from entries using tree traversal. * If leafId is provided, walks from that entry to root. * Handles compaction and branch summaries along the path. */ export function buildSessionContext( entries: SessionEntry[], leafId?: string | null, byId?: Map, ): SessionContext { // Build uuid index if not available if (!byId) { byId = new Map(); for (const entry of entries) { byId.set(entry.id, entry); } } // Find leaf let leaf: SessionEntry | undefined; if (leafId === null) { // Explicitly null - return no messages (navigated to before first entry) return { messages: [], thinkingLevel: "off", serviceTier: undefined, models: {}, injectedTtsrRules: [], selectedMCPToolNames: [], hasPersistedMCPToolSelection: false, mode: "none", }; } if (leafId) { leaf = byId.get(leafId); } if (!leaf) { // Fallback to last entry (when leafId is undefined) leaf = entries[entries.length - 1]; } if (!leaf) { return { messages: [], thinkingLevel: "off", serviceTier: undefined, models: {}, injectedTtsrRules: [], selectedMCPToolNames: [], hasPersistedMCPToolSelection: false, mode: "none", }; } // Walk from leaf to root, collecting path const path: SessionEntry[] = []; let current: SessionEntry | undefined = leaf; while (current) { path.unshift(current); current = current.parentId ? byId.get(current.parentId) : undefined; } // Extract settings and find compaction let thinkingLevel: string | undefined = "off"; let serviceTier: ServiceTier | undefined; const models: Record = {}; let compaction: CompactionEntry | null = null; const injectedTtsrRulesSet = new Set(); let selectedMCPToolNames: string[] = []; let hasPersistedMCPToolSelection = false; let mode = "none"; let modeData: Record | undefined; // Track whether an explicit `model_change` with role="default" has been // seen on this path. Once a user (or the agent itself) records an // explicit default, later assistant-message inference must NOT overwrite // it: temporary fallbacks (retry fallback, context promotion) and // server-side model downgrades both produce assistant messages tagged // with the wrong model id, which previously clobbered the user's pick on // resume (issue #849). let hasExplicitDefaultModel = false; for (const entry of path) { if (entry.type === "thinking_level_change") { thinkingLevel = entry.thinkingLevel ?? "off"; } else if (entry.type === "model_change") { // New format: { model: "provider/id", role?: string } if (entry.model) { const role = entry.role ?? "default"; models[role] = entry.model; if (role === "default") { hasExplicitDefaultModel = true; } } } else if (entry.type === "service_tier_change") { serviceTier = entry.serviceTier ?? undefined; } else if (entry.type === "message" && entry.message.role === "assistant") { // Legacy fallback: infer default model from assistant messages only // when no explicit `model_change` (role=default) entry has been // recorded yet. Newer sessions always record an explicit default // model_change at the start of the conversation, so this branch is // only used to keep pre-model_change sessions working. if (!hasExplicitDefaultModel) { models.default = `${entry.message.provider}/${entry.message.model}`; } } else if (entry.type === "compaction") { compaction = entry; } else if (entry.type === "ttsr_injection") { // Collect injected TTSR rule names for (const ruleName of entry.injectedRules) { injectedTtsrRulesSet.add(ruleName); } } else if (entry.type === "mcp_tool_selection") { selectedMCPToolNames = [...entry.selectedToolNames]; hasPersistedMCPToolSelection = true; } else if (entry.type === "mode_change") { mode = entry.mode; modeData = entry.data; } } const injectedTtsrRules = Array.from(injectedTtsrRulesSet); // Build messages and collect corresponding entries // When there's a compaction, we need to: // 1. Emit summary first (entry = compaction) // 2. Emit kept messages (from firstKeptEntryId up to compaction) // 3. Emit messages after compaction const messages: AgentMessage[] = []; const appendMessage = (entry: SessionEntry) => { if (entry.type === "message") { messages.push(entry.message); } else if (entry.type === "custom_message") { messages.push( createCustomMessage( entry.customType, entry.content, entry.display, entry.details, entry.timestamp, entry.attribution, ), ); } else if (entry.type === "branch_summary" && entry.summary) { messages.push(createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp)); } }; if (compaction) { const providerPayload: ProviderPayload | undefined = (() => { const candidate = compaction.preserveData?.openaiRemoteCompaction; if (!candidate || typeof candidate !== "object") return undefined; const remote = candidate as { provider?: unknown; replacementHistory?: unknown }; if (typeof remote.provider !== "string" || remote.provider.length === 0) return undefined; if (!Array.isArray(remote.replacementHistory)) return undefined; return { type: "openaiResponsesHistory", provider: remote.provider, items: remote.replacementHistory as Array>, }; })(); const remoteReplacementHistory = providerPayload?.items; // Emit summary first messages.push( createCompactionSummaryMessage( compaction.summary, compaction.tokensBefore, compaction.timestamp, compaction.shortSummary, providerPayload, ), ); // Find compaction index in path const compactionIdx = path.findIndex(e => e.type === "compaction" && e.id === compaction.id); if (!remoteReplacementHistory) { // Emit kept messages (before compaction, starting from firstKeptEntryId) let foundFirstKept = false; for (let i = 0; i < compactionIdx; i++) { const entry = path[i]; if (entry.id === compaction.firstKeptEntryId) { foundFirstKept = true; } if (foundFirstKept) { appendMessage(entry); } } } // Emit messages after compaction for (let i = compactionIdx + 1; i < path.length; i++) { const entry = path[i]; appendMessage(entry); } } else { // No compaction - emit all messages, handle branch summaries and custom messages for (const entry of path) { appendMessage(entry); } } return { messages, thinkingLevel, serviceTier, models, injectedTtsrRules, selectedMCPToolNames, hasPersistedMCPToolSelection, mode, modeData, }; } /** * Compute the default session directory for a cwd. * Classifies cwd by canonical location so symlink/alias paths resolve to the * same home-relative or temp-root directory names as their real targets. */ function computeDefaultSessionDir( cwd: string, storage: SessionStorage, sessionsRoot: string = getSessionsDir(), ): string { const { encodedDirName, resolvedCwd } = getDefaultSessionDirName(cwd); migrateHomeSessionDirs(sessionsRoot); const sessionDir = path.join(sessionsRoot, encodedDirName); migrateLegacyAbsoluteSessionDir(resolvedCwd, sessionDir, sessionsRoot); storage.ensureDirSync(sessionDir); return sessionDir; } // ============================================================================= // Terminal breadcrumbs: maps terminal (TTY) -> last session file for --continue // ============================================================================= /** * Write a breadcrumb linking the current terminal to a session file. * The breadcrumb contains the cwd and session path so --continue can * find "this terminal's last session" even when running concurrent instances. */ function writeTerminalBreadcrumb(cwd: string, sessionFile: string): void { const terminalId = getTerminalId(); if (!terminalId) return; const breadcrumbDir = getTerminalSessionsDir(); const breadcrumbFile = path.join(breadcrumbDir, terminalId); const content = `${cwd}\n${sessionFile}\n`; // Best-effort — don't break session creation if breadcrumb fails Bun.write(breadcrumbFile, content).catch(() => {}); } /** * Read the terminal breadcrumb for the current terminal, scoped to a cwd. * Returns the session file path if it exists and matches the cwd, null otherwise. */ async function readTerminalBreadcrumb(cwd: string): Promise { const terminalId = getTerminalId(); if (!terminalId) return null; try { const breadcrumbFile = path.join(getTerminalSessionsDir(), terminalId); const content = await Bun.file(breadcrumbFile).text(); const lines = content.trim().split("\n"); if (lines.length < 2) return null; const breadcrumbCwd = lines[0]; const sessionFile = lines[1]; // Only return if cwd matches (user might have cd'd) if (path.resolve(breadcrumbCwd) !== path.resolve(cwd)) return null; // Verify the session file still exists const stat = fs.statSync(sessionFile, { throwIfNoEntry: false }); if (stat?.isFile()) return sessionFile; } catch (err) { if (!isEnoent(err)) logger.debug("Terminal breadcrumb read failed", { err }); // Breadcrumb doesn't exist or is corrupt — fall through } return null; } /** Exported for testing */ export async function loadEntriesFromFile( filePath: string, storage: SessionStorage = new FileSessionStorage(), ): Promise { let content: string; try { content = await storage.readText(filePath); } catch (err) { if (isEnoent(err)) return []; throw err; } const entries = parseJsonlLenient(content); // Validate session header if (entries.length === 0) return entries; const header = entries[0] as SessionHeader; if (header.type !== "session" || typeof header.id !== "string") { return []; } return entries; } /** * Resolve blob references in loaded entries, restoring both session image blocks and persisted * provider image URLs back to the inline data expected by downstream transports. Mutates entries in place. */ function hasImageUrl(value: unknown): value is { image_url: string } { return typeof value === "object" && value !== null && "image_url" in value && typeof value.image_url === "string"; } async function resolvePersistedImageUrlRefs(value: unknown, blobStore: BlobStore): Promise { if (Array.isArray(value)) { await Promise.all(value.map(item => resolvePersistedImageUrlRefs(item, blobStore))); return; } if (typeof value !== "object" || value === null) return; if (hasImageUrl(value) && isBlobRef(value.image_url)) { value.image_url = await resolveImageDataUrl(blobStore, value.image_url); } await Promise.all(Object.values(value).map(item => resolvePersistedImageUrlRefs(item, blobStore))); } async function resolveBlobRefsInEntries(entries: FileEntry[], blobStore: BlobStore): Promise { const promises: Promise[] = []; for (const entry of entries) { if (entry.type === "session") continue; let contentArray: unknown[] | undefined; if (entry.type === "message" && "content" in entry.message && Array.isArray(entry.message.content)) { contentArray = entry.message.content; } else if (entry.type === "custom_message" && Array.isArray(entry.content)) { contentArray = entry.content; } if (contentArray) { for (const block of contentArray) { if (isImageBlock(block) && isBlobRef(block.data)) { promises.push( resolveImageData(blobStore, block.data).then(resolved => { block.data = resolved; }), ); } } } promises.push(resolvePersistedImageUrlRefs(entry, blobStore)); } await Promise.all(promises); } /** * Lightweight metadata for a session file, used in session picker UI. * Uses lazy getters to defer string formatting until actually displayed. */ function sanitizeSessionName(value: string | undefined): string | undefined { if (!value) return undefined; const firstLine = value.split(/\r?\n/)[0] ?? ""; const stripped = firstLine.replace(/[\x00-\x1F\x7F]/g, ""); const trimmed = stripped.trim(); return trimmed.length > 0 ? trimmed : undefined; } class RecentSessionInfo { #fullName: string | undefined; #timeAgo: string | undefined; readonly #headerTimestamp: string | undefined; constructor( readonly path: string, readonly mtime: number, header: Record, firstPrompt?: string, ) { // Prefer an explicit title, then the first user prompt. The raw UUID `id` is // intentionally not used as a fallback: showing it as a "name" is unfriendly and // indistinguishable from neighboring sessions in the UI. The friendly fallback is // derived lazily in `fullName` from the session timestamp. const trystr = (v: unknown) => (typeof v === "string" ? v : undefined); this.#fullName = sanitizeSessionName(trystr(header.title)) ?? sanitizeSessionName(firstPrompt); this.#headerTimestamp = trystr(header.timestamp); } /** Display name. Falls back to a timestamp-based label, never the raw UUID. */ get fullName(): string { if (this.#fullName) return this.#fullName; const ts = this.#headerTimestamp ? Date.parse(this.#headerTimestamp) : Number.NaN; const date = new Date(Number.isFinite(ts) ? ts : this.mtime); const time = date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); this.#fullName = `Untitled · ${time}`; return this.#fullName; } /** * Display name without an arbitrary length cap. The renderer is responsible for * width-aware truncation so adjacent fields (e.g. the relative time) stay visible. */ get name(): string { return this.fullName; } /** Human-readable relative time (e.g., "2 hours ago") */ get timeAgo(): string { if (this.#timeAgo) return this.#timeAgo; this.#timeAgo = formatTimeAgo(new Date(this.mtime)); return this.#timeAgo; } } /** * Extracts the text content from a user message entry. * Returns undefined if the entry is not a user message or has no text. */ function extractFirstUserPrompt(entries: Array>): string | undefined { for (const entry of entries) { if (entry.type !== "message") continue; const message = entry.message as Record | undefined; if (message?.role !== "user") continue; const content = message.content; if (typeof content === "string") return content; if (Array.isArray(content)) { for (const block of content) { if (typeof block === "object" && block !== null && "text" in block) { const text = (block as { text: unknown }).text; if (typeof text === "string") return text; } } } } return undefined; } /** * Reads all session files from the directory and returns them sorted by mtime (newest first). * Uses low-level file I/O to efficiently read only the first 4KB of each file * to extract the JSON header and first user message without loading entire session logs into memory. */ async function getSortedSessions(sessionDir: string, storage: SessionStorage): Promise { try { const files: string[] = storage.listFilesSync(sessionDir, "*.jsonl"); const sessions: RecentSessionInfo[] = []; await Promise.all( files.map(async (path: string) => { try { const content = await storage.readTextPrefix(path, 4096); const entries = parseJsonlLenient>(content); if (entries.length === 0) return; const header = entries[0] as Record; if (header.type !== "session" || typeof header.id !== "string") return; const mtime = storage.statSync(path).mtimeMs; const firstPrompt = header.title ? undefined : extractFirstUserPrompt(entries); sessions.push(new RecentSessionInfo(path, mtime, header, firstPrompt)); } catch {} }), ); return sessions.sort((a, b) => b.mtime - a.mtime); } catch { return []; } } /** Exported for testing */ export async function findMostRecentSession( sessionDir: string, storage: SessionStorage = new FileSessionStorage(), ): Promise { const sessions = await getSortedSessions(sessionDir, storage); return sessions[0]?.path || null; } /** Format a time difference as a human-readable string */ function formatTimeAgo(date: Date): string { const now = Date.now(); const diffMs = now - date.getTime(); const diffMins = Math.floor(diffMs / 60000); const diffHours = Math.floor(diffMs / 3600000); const diffDays = Math.floor(diffMs / 86400000); if (diffMins < 1) return "just now"; if (diffMins < 60) return `${diffMins}m ago`; if (diffHours < 24) return `${diffHours}h ago`; if (diffDays < 7) return `${diffDays}d ago`; return date.toLocaleDateString(); } const MAX_PERSIST_CHARS = 500_000; const TRUNCATION_NOTICE = "\n\n[Session persistence truncated large content]"; /** Minimum base64 length to externalize to blob store (skip tiny inline images) */ const BLOB_EXTERNALIZE_THRESHOLD = 1024; const TEXT_CONTENT_KEY = "content"; /** * Recursively truncate large strings in an object for session persistence. * - Truncates any oversized string fields (key-agnostic) * - Replaces oversized image blocks with text notices * - Updates lineCount when content is truncated * - Returns original object if no changes needed (structural sharing) */ function truncateString(value: string, maxLength: number): string { if (value.length <= maxLength) return value; let truncated = value.slice(0, maxLength); if (truncated.length > 0) { const last = truncated.charCodeAt(truncated.length - 1); if (last >= 0xd800 && last <= 0xdbff) { truncated = truncated.slice(0, -1); } } return truncated; } function isImageBlock(value: unknown): value is { type: "image"; data: string; mimeType?: string } { return ( typeof value === "object" && value !== null && "type" in value && (value as { type?: string }).type === "image" && "data" in value && typeof (value as { data?: string }).data === "string" ); } async function truncateForPersistence(obj: FileEntry, blobStore: BlobStore, key?: string): Promise; async function truncateForPersistence(obj: string, blobStore: BlobStore, key?: string): Promise; async function truncateForPersistence(obj: unknown[], blobStore: BlobStore, key?: string): Promise; async function truncateForPersistence(obj: object, blobStore: BlobStore, key?: string): Promise; async function truncateForPersistence( obj: null | undefined, blobStore: BlobStore, key?: string, ): Promise; async function truncateForPersistence(obj: unknown, blobStore: BlobStore, key?: string): Promise { if (obj === null || obj === undefined) return obj; if (typeof obj === "string") { if (key === "image_url" && isImageDataUrl(obj)) { return externalizeImageDataUrl(blobStore, obj); } if (obj.length > MAX_PERSIST_CHARS) { // Cryptographic signatures must be preserved exactly or cleared entirely — never truncated. // Truncation would produce an invalid signature that the API rejects. if (key === "thinkingSignature" || key === "thoughtSignature" || key === "textSignature") { return ""; } const limit = Math.max(0, MAX_PERSIST_CHARS - TRUNCATION_NOTICE.length); return `${truncateString(obj, limit)}${TRUNCATION_NOTICE}`; } return obj; } if (Array.isArray(obj)) { let changed = false; const result = await Promise.all( obj.map(async item => { // Special handling: compress oversized images while preserving shape if (key === TEXT_CONTENT_KEY && isImageBlock(item)) { if (!isBlobRef(item.data) && item.data.length >= BLOB_EXTERNALIZE_THRESHOLD) { changed = true; const blobRef = await externalizeImageData(blobStore, item.data); return { ...item, data: blobRef }; } } const newItem = await truncateForPersistence(item, blobStore, key); if (newItem !== item) changed = true; return newItem; }), ); return changed ? result : obj; } if (typeof obj === "object") { let changed = false; const entries: Array = await Promise.all( Object.entries(obj).flatMap(([childKey, value]) => { // Strip transient/redundant properties that shouldn't be persisted. // - partialJson: streaming accumulator for tool call JSON parsing // - jsonlEvents: raw subprocess streaming events (already saved to artifact files) if (childKey === "partialJson" || childKey === "jsonlEvents") { changed = true; return []; } return [ (async () => { const newValue = await truncateForPersistence(value, blobStore, childKey); if (newValue !== value) changed = true; return [childKey, newValue] as const; })(), ]; }), ); if (!changed) return obj; const contentEntry = entries.find(([childKey]) => childKey === "content"); const lineCountEntry = entries.find(([childKey]) => childKey === "lineCount"); if ( contentEntry && typeof contentEntry[1] === "string" && lineCountEntry && typeof lineCountEntry[1] === "number" ) { const content = contentEntry[1]; const updatedEntries = entries.map(([childKey, value]) => childKey === "lineCount" ? ([childKey, content.split("\n").length] as const) : ([childKey, value] as const), ); return Object.fromEntries(updatedEntries); } return Object.fromEntries(entries); } return obj; } async function prepareEntryForPersistence(entry: FileEntry, blobStore: BlobStore): Promise { return truncateForPersistence(entry, blobStore); } /** * Synchronous variant of {@link truncateForPersistence}. * * The async version's overhead — `Promise.all` over `Object.entries`/`Array.prototype.map`, * one microtask hop per nested node — is pure waste for entries without image blobs * (the vast majority). The fast path runs in one synchronous tick so an OOM/SIGKILL * landing right after `_persist` returns cannot lose the entry. Image externalization * still happens, but via the synchronous blob-store path (`fs.writeFileSync`), so the * blob bytes are in the kernel page cache before the JSONL line referencing them is * written. */ function truncateForPersistenceSync(obj: unknown, blobStore: BlobStore, key?: string): unknown { if (obj === null || obj === undefined) return obj; if (typeof obj === "string") { if (key === "image_url" && isImageDataUrl(obj)) { return externalizeImageDataUrlSync(blobStore, obj); } if (obj.length > MAX_PERSIST_CHARS) { if (key === "thinkingSignature" || key === "thoughtSignature" || key === "textSignature") { return ""; } const limit = Math.max(0, MAX_PERSIST_CHARS - TRUNCATION_NOTICE.length); return `${truncateString(obj, limit)}${TRUNCATION_NOTICE}`; } return obj; } if (Array.isArray(obj)) { let changed = false; const result: unknown[] = new Array(obj.length); for (let i = 0; i < obj.length; i++) { const item = obj[i]; if (key === TEXT_CONTENT_KEY && isImageBlock(item)) { if (!isBlobRef(item.data) && item.data.length >= BLOB_EXTERNALIZE_THRESHOLD) { changed = true; const blobRef = externalizeImageDataSync(blobStore, item.data); result[i] = { ...item, data: blobRef }; continue; } } const newItem = truncateForPersistenceSync(item, blobStore, key); if (newItem !== item) changed = true; result[i] = newItem; } return changed ? result : obj; } if (typeof obj === "object") { let changed = false; const entries: Array = []; for (const [childKey, value] of Object.entries(obj)) { if (childKey === "partialJson" || childKey === "jsonlEvents") { changed = true; continue; } const newValue = truncateForPersistenceSync(value, blobStore, childKey); if (newValue !== value) changed = true; entries.push([childKey, newValue]); } if (!changed) return obj; const contentEntry = entries.find(([childKey]) => childKey === "content"); const lineCountEntry = entries.find(([childKey]) => childKey === "lineCount"); if ( contentEntry && typeof contentEntry[1] === "string" && lineCountEntry && typeof lineCountEntry[1] === "number" ) { const content = contentEntry[1]; const updatedEntries = entries.map(([childKey, value]) => childKey === "lineCount" ? ([childKey, content.split("\n").length] as const) : ([childKey, value] as const), ); return Object.fromEntries(updatedEntries); } return Object.fromEntries(entries); } return obj; } function prepareEntryForPersistenceSync(entry: FileEntry, blobStore: BlobStore): FileEntry { return truncateForPersistenceSync(entry, blobStore) as FileEntry; } class NdjsonFileWriter { #writer: SessionStorageWriter; #closed = false; #closing = false; #error: Error | undefined; #pendingWrites: Promise = Promise.resolve(); #onError: ((err: Error) => void) | undefined; constructor(storage: SessionStorage, path: string, options?: { flags?: "a" | "w"; onError?: (err: Error) => void }) { this.#onError = options?.onError; this.#writer = storage.openWriter(path, { flags: options?.flags ?? "a", onError: (err: Error) => this.#recordError(err), }); } #recordError(err: unknown): Error { const writeErr = toError(err); if (!this.#error) this.#error = writeErr; this.#onError?.(writeErr); return writeErr; } #enqueue(task: () => Promise): Promise { const run = async () => { if (this.#error) throw this.#error; await task(); }; const next = this.#pendingWrites.then(run); void next.catch((err: unknown) => { if (!this.#error) this.#error = toError(err); }); this.#pendingWrites = next; return next; } async #writeLine(line: string): Promise { if (this.#error) throw this.#error; try { await this.#writer.writeLine(line); } catch (err) { throw this.#recordError(err); } } /** Queue a write. Returns a promise so callers can await if needed. */ write(entry: FileEntry): Promise { if (this.#closed || this.#closing) throw new Error("Writer closed"); if (this.#error) throw this.#error; const line = `${JSON.stringify(entry)}\n`; return this.#enqueue(() => this.#writeLine(line)); } /** * Synchronously serialize and append the entry. Returns once `fs.writeSync` has handed * the bytes to the kernel page cache — durable across OOM/SIGKILL even before fsync. * * Callers MUST NOT mix this with pending async `write()` calls on the same writer: * the async path is queued through `#pendingWrites`, but this method bypasses the * queue. Use only when no concurrent async write is in flight (the session-manager * persist path enforces this via `#flushed`/`#needsFullRewriteOnNextPersist`). */ writeSync(entry: FileEntry): void { if (this.#closed || this.#closing) throw new Error("Writer closed"); if (this.#error) throw this.#error; const line = `${JSON.stringify(entry)}\n`; try { this.#writer.writeLineSync(line); } catch (err) { throw this.#recordError(err); } } /** Flush all buffered data to disk. Waits for all queued writes. */ async flush(): Promise { if (this.#closed) return; if (this.#error) throw this.#error; await this.#enqueue(async () => {}); if (this.#error) throw this.#error; try { await this.#writer.flush(); } catch (err) { throw this.#recordError(err); } } /** Sync data to persistent storage. */ async fsync(): Promise { if (this.#closed) return; if (this.#error) throw this.#error; try { await this.#writer.fsync(); } catch (err) { throw this.#recordError(err); } } /** Close the writer, flushing all data. */ async close(): Promise { if (this.#closed || this.#closing) return; this.#closing = true; let closeError: Error | undefined; try { await this.flush(); } catch (err) { closeError = toError(err); } try { await this.#pendingWrites; } catch (err) { if (!closeError) closeError = toError(err); } try { await this.#writer.close(); } catch (err) { const endErr = this.#recordError(err); if (!closeError) closeError = endErr; } this.#closed = true; if (!closeError && this.#error) closeError = this.#error; if (closeError) throw closeError; } /** Check if there's a stored error. */ getError(): Error | undefined { return this.#error; } /** True while the writer accepts new writes (not closing or closed). */ isOpen(): boolean { return !this.#closed && !this.#closing; } } /** Get recent sessions for display in welcome screen */ export async function getRecentSessions( sessionDir: string, limit = 3, storage: SessionStorage = new FileSessionStorage(), ): Promise { const sessions = await getSortedSessions(sessionDir, storage); return sessions.slice(0, limit); } /** * Manages conversation sessions as append-only trees stored in JSONL files. * * Each session entry has an id and parentId forming a tree structure. The "leaf" * pointer tracks the current position. Appending creates a child of the current leaf. * Branching moves the leaf to an earlier entry, allowing new branches without * modifying history. * * Use buildSessionContext() to get the resolved message list for the LLM, which * handles compaction summaries and follows the path from root to current leaf. */ export interface UsageStatistics { input: number; output: number; cacheRead: number; cacheWrite: number; premiumRequests: number; cost: number; } function getTaskToolUsage(details: unknown): Usage | undefined { if (!details || typeof details !== "object") return undefined; const record = details as Record; const usage = record.usage; if (!usage || typeof usage !== "object") return undefined; return usage as Usage; } function extractTextFromContent(content: Message["content"]): string { if (typeof content === "string") return content; return content .filter((block): block is TextContent => block.type === "text") .map(block => block.text) .join(" "); } const SESSION_LIST_PREFIX_BYTES = 4096; const SESSION_LIST_PARALLEL_THRESHOLD = 64; const SESSION_LIST_MAX_WORKERS = 16; const sessionListPrefixDecoder = new TextDecoder("utf-8", { fatal: false }); async function readSessionListPrefix(file: string, storage: SessionStorage, buffer: Buffer): Promise { if (!(storage instanceof FileSessionStorage)) { return storage.readTextPrefix(file, buffer.byteLength); } const handle = await fs.promises.open(file, "r"); try { const { bytesRead } = await handle.read(buffer, 0, buffer.byteLength, 0); return sessionListPrefixDecoder.decode(buffer.subarray(0, bytesRead)); } finally { await handle.close(); } } function decodeJsonStringFragment(value: string): string { const safeValue = value.endsWith("\\") ? value.slice(0, -1) : value; try { return JSON.parse(`"${safeValue}"`) as string; } catch { return safeValue .replace(/\\n/g, "\n") .replace(/\\r/g, "\r") .replace(/\\t/g, "\t") .replace(/\\"/g, '"') .replace(/\\\\/g, "\\"); } } function extractStringProperty(source: string, name: string, startIndex = 0): string | undefined { const propertyIndex = source.indexOf(`"${name}"`, startIndex); if (propertyIndex === -1) return undefined; const colonIndex = source.indexOf(":", propertyIndex + name.length + 2); if (colonIndex === -1) return undefined; let valueIndex = colonIndex + 1; while (valueIndex < source.length) { const char = source.charCodeAt(valueIndex); if (char !== 32 && char !== 9 && char !== 10 && char !== 13) break; valueIndex++; } if (source.charCodeAt(valueIndex) !== 34) return undefined; const valueStart = valueIndex + 1; let escaped = false; for (let i = valueStart; i < source.length; i++) { const char = source.charCodeAt(i); if (escaped) { escaped = false; continue; } if (char === 92) { escaped = true; continue; } if (char === 34) { return decodeJsonStringFragment(source.slice(valueStart, i)); } } return decodeJsonStringFragment(source.slice(valueStart)); } function countMessageMarkers(content: string): number { let count = 0; let index = 0; while (index < content.length) { const typeIndex = content.indexOf('"type"', index); if (typeIndex === -1) break; const colonIndex = content.indexOf(":", typeIndex + 6); if (colonIndex === -1) break; const type = extractStringProperty(content, "type", typeIndex); if (type === "message") count++; index = colonIndex + 1; } return count; } function extractFirstUserMessageFromPrefix(content: string): string | undefined { const roleIndex = content.indexOf('"role"'); if (roleIndex === -1) return undefined; let index = roleIndex; while (index !== -1) { const role = extractStringProperty(content, "role", index); if (role === "user") { return extractStringProperty(content, "content", index) ?? extractStringProperty(content, "text", index); } index = content.indexOf('"role"', index + 6); } return undefined; } interface SessionListHeader { type: "session"; id: string; cwd?: string; title?: string; parentSession?: string; timestamp?: string; } function parseSessionListHeader( content: string, entries: Array>, ): SessionListHeader | undefined { const parsedHeader = entries[0]; if (parsedHeader?.type === "session" && typeof parsedHeader.id === "string") { return { type: "session", id: parsedHeader.id, cwd: typeof parsedHeader.cwd === "string" ? parsedHeader.cwd : undefined, title: typeof parsedHeader.title === "string" ? parsedHeader.title : undefined, parentSession: typeof parsedHeader.parentSession === "string" ? parsedHeader.parentSession : undefined, timestamp: typeof parsedHeader.timestamp === "string" ? parsedHeader.timestamp : undefined, }; } const firstLineEnd = content.indexOf("\n"); const firstLine = firstLineEnd === -1 ? content : content.slice(0, firstLineEnd); if (extractStringProperty(firstLine, "type") !== "session") return undefined; const id = extractStringProperty(firstLine, "id"); if (!id) return undefined; return { type: "session", id, cwd: extractStringProperty(firstLine, "cwd"), title: extractStringProperty(firstLine, "title"), parentSession: extractStringProperty(firstLine, "parentSession"), timestamp: extractStringProperty(firstLine, "timestamp"), }; } function getSessionListWorkerCount(fileCount: number): number { if (fileCount <= SESSION_LIST_PARALLEL_THRESHOLD) return 1; return Math.min( SESSION_LIST_MAX_WORKERS, os.availableParallelism(), Math.ceil(fileCount / SESSION_LIST_PARALLEL_THRESHOLD), ); } async function collectSessionFromFile( file: string, storage: SessionStorage, buffer: Buffer, ): Promise { try { const content = await readSessionListPrefix(file, storage, buffer); const entries = parseJsonlLenient>(content); const header = parseSessionListHeader(content, entries); if (!header) return undefined; let parsedMessageCount = 0; let firstMessage = ""; const allMessages: string[] = []; let shortSummary: string | undefined; for (let i = 1; i < entries.length; i++) { const entry = entries[i] as { type?: string; message?: Message; shortSummary?: string }; if (entry.type === "compaction" && typeof entry.shortSummary === "string") { shortSummary = entry.shortSummary; } if (entry.type === "message" && entry.message) { parsedMessageCount++; if (entry.message.role === "user" || entry.message.role === "assistant") { const textContent = extractTextFromContent(entry.message.content); if (textContent) { allMessages.push(textContent); if (!firstMessage && entry.message.role === "user") { firstMessage = textContent; } } } } } firstMessage ||= extractFirstUserMessageFromPrefix(content) ?? ""; const messageCount = Math.max(parsedMessageCount, countMessageMarkers(content)); const stats = storage.statSync(file); return { path: file, id: header.id, cwd: header.cwd ?? "", title: header.title ?? shortSummary, parentSessionPath: header.parentSession, created: new Date(header.timestamp ?? ""), modified: stats.mtime, messageCount, size: stats.size, firstMessage: firstMessage || "(no messages)", allMessagesText: allMessages.length > 0 ? allMessages.join(" ") : firstMessage, }; } catch { return undefined; } } async function collectSessionsFromFileStride( files: string[], storage: SessionStorage, startIndex: number, stride: number, ): Promise { const sessions: SessionInfo[] = []; const buffer = Buffer.allocUnsafe(SESSION_LIST_PREFIX_BYTES); for (let i = startIndex; i < files.length; i += stride) { const session = await collectSessionFromFile(files[i], storage, buffer); if (session) sessions.push(session); } return sessions; } async function collectSessionsFromFiles(files: string[], storage: SessionStorage): Promise { const workerCount = getSessionListWorkerCount(files.length); const sessions = workerCount === 1 ? await collectSessionsFromFileStride(files, storage, 0, 1) : ( await Promise.all( Array.from({ length: workerCount }, (_, workerIndex) => collectSessionsFromFileStride(files, storage, workerIndex, workerCount), ), ) ).flat(); sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime()); return sessions; } export interface ResolvedSessionMatch { session: SessionInfo; scope: "local" | "global"; } function sessionMatchesResumeArg(session: SessionInfo, sessionArg: string): boolean { const normalizedArg = sessionArg.toLowerCase(); const normalizedId = session.id.toLowerCase(); if (normalizedId.startsWith(normalizedArg)) { return true; } const fileName = path.basename(session.path, ".jsonl").toLowerCase(); if (fileName.startsWith(normalizedArg)) { return true; } const separator = fileName.lastIndexOf("_"); if (separator < 0) { return false; } const fileSessionId = fileName.slice(separator + 1); return fileSessionId.startsWith(normalizedArg); } export async function resolveResumableSession( sessionArg: string, cwd: string, sessionDir?: string, storage: SessionStorage = new FileSessionStorage(), ): Promise { const localSessionDir = sessionDir ?? SessionManager.getDefaultSessionDir(cwd, undefined, storage); const localSessions = await SessionManager.list(cwd, localSessionDir, storage); const localMatch = localSessions.find(session => sessionMatchesResumeArg(session, sessionArg)); if (localMatch) { return { session: localMatch, scope: "local" }; } if (sessionDir) { return undefined; } const globalSessions = await SessionManager.listAll(storage); const globalMatch = globalSessions.find(session => sessionMatchesResumeArg(session, sessionArg)); if (!globalMatch) { return undefined; } return { session: globalMatch, scope: "global" }; } interface SessionManagerStateSnapshot { sessionId: string; sessionName: string | undefined; titleSource: "auto" | "user" | undefined; sessionFile: string | undefined; flushed: boolean; needsFullRewriteOnNextPersist: boolean; fileEntries: FileEntry[]; } export class SessionManager { #sessionId: string = ""; #sessionName: string | undefined; #titleSource: "auto" | "user" | undefined; #sessionFile: string | undefined; #flushed: boolean = false; #needsFullRewriteOnNextPersist: boolean = false; #ensuredOnDisk: boolean = false; #fileEntries: FileEntry[] = []; #byId: Map = new Map(); #labelsById: Map = new Map(); #leafId: string | null = null; #usageStatistics = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, premiumRequests: 0, cost: 0, } satisfies UsageStatistics; #persistWriter: NdjsonFileWriter | undefined; #persistWriterPath: string | undefined; #persistChain: Promise = Promise.resolve(); #persistError: Error | undefined; #persistErrorReported = false; #artifactManager: ArtifactManager | null = null; #artifactManagerSessionFile: string | null = null; // When set, take precedence over the lazily-derived per-session manager. // Subagents adopt the parent's manager so artifact IDs are unique across the // whole agent tree and all files land in the parent's artifacts dir. #adoptedArtifactManager: ArtifactManager | null = null; // In-memory artifact fallback for non-persistent sessions (persist=false). // Keyed by sequential numeric ID string; mirrors the file-based ArtifactManager ID scheme. #inMemoryArtifacts: Map | null = null; #inMemoryArtifactCounter = 0; readonly #blobStore: BlobStore; private constructor( private cwd: string, private sessionDir: string, private readonly persist: boolean, private readonly storage: SessionStorage, ) { this.#blobStore = new BlobStore(getBlobsDir()); if (persist && sessionDir) { this.storage.ensureDirSync(sessionDir); } // Note: call _initSession() or _initSessionFile() after construction } /** Puts a binary blob into the blob store and returns the blob reference */ async putBlob(data: Buffer): Promise { return this.#blobStore.put(data); } captureState(): SessionManagerStateSnapshot { return { sessionId: this.#sessionId, sessionName: this.#sessionName, titleSource: this.#titleSource, sessionFile: this.#sessionFile, flushed: this.#flushed, needsFullRewriteOnNextPersist: this.#needsFullRewriteOnNextPersist, // Snapshot entry objects by reference: switch/reload replaces the active entry array, // so rollback does not need structured cloning of extension/custom details. fileEntries: [...this.#fileEntries], }; } restoreState(snapshot: SessionManagerStateSnapshot): void { this.#sessionId = snapshot.sessionId; this.#sessionName = snapshot.sessionName; this.#titleSource = snapshot.titleSource; this.#sessionFile = snapshot.sessionFile; this.#flushed = snapshot.flushed; this.#needsFullRewriteOnNextPersist = snapshot.needsFullRewriteOnNextPersist; this.#fileEntries = [...snapshot.fileEntries]; this.#persistWriter = undefined; this.#persistWriterPath = undefined; this.#persistChain = Promise.resolve(); this.#persistError = undefined; this.#persistErrorReported = false; this.#artifactManager = null; this.#artifactManagerSessionFile = null; this.#adoptedArtifactManager = null; this.#buildIndex(); if (this.#sessionFile) { writeTerminalBreadcrumb(this.cwd, this.#sessionFile); } } /** Initialize with a specific session file (used by factory methods) */ async #initSessionFile(sessionFile: string): Promise { await this.setSessionFile(sessionFile); } /** Initialize with a new session (used by factory methods) */ #initNewSession(): void { this.#newSessionSync(); } /** Switch to a different session file (used for resume and branching) */ async setSessionFile(sessionFile: string): Promise { await this.#closePersistWriter(); this.#persistError = undefined; this.#persistErrorReported = false; this.#sessionFile = path.resolve(sessionFile); writeTerminalBreadcrumb(this.cwd, this.#sessionFile); this.#fileEntries = await loadEntriesFromFile(this.#sessionFile, this.storage); if (this.#fileEntries.length > 0) { const header = this.#fileEntries.find(e => e.type === "session") as SessionHeader | undefined; this.#sessionId = header?.id ?? createSessionId(); this.#sessionName = header?.title; this.#titleSource = header?.titleSource; this.#needsFullRewriteOnNextPersist = migrateToCurrentVersion(this.#fileEntries); await resolveBlobRefsInEntries(this.#fileEntries, this.#blobStore); this.sanitizeLoadedOpenAIResponsesReplayMetadata(); this.#buildIndex(); this.#flushed = true; this.#ensuredOnDisk = true; } else { const explicitPath = this.#sessionFile; this.#newSessionSync(); this.#sessionFile = explicitPath; // preserve explicit path from --session flag await this.#rewriteFile(); this.#flushed = true; this.#ensuredOnDisk = true; return; } } /** Start a new session. Closes any existing writer first. */ async newSession(options?: NewSessionOptions): Promise { await this.#closePersistWriter(); return this.#newSessionSync(options); } /** Delete a session file and its artifacts. Drains the persist writer first to avoid EPERM on Windows. ENOENT is treated as success. */ async dropSession(sessionPath: string): Promise { await this.#closePersistWriter(); try { await this.storage.deleteSessionWithArtifacts(sessionPath); } catch (err) { if (isEnoent(err)) return; throw err; } } /** * Fork the current session, creating a new session file with the same entries. * Returns both the old and new session file paths for artifact copying. * @returns { oldSessionFile, newSessionFile } or undefined if not persisting */ async fork(): Promise<{ oldSessionFile: string; newSessionFile: string } | undefined> { if (!this.persist || !this.#sessionFile) { return undefined; } const oldSessionFile = this.#sessionFile; const oldSessionId = this.#sessionId; // Close the current writer await this.#closePersistWriter(); this.#persistChain = Promise.resolve(); this.#persistError = undefined; this.#persistErrorReported = false; // Create new session ID and header this.#sessionId = createSessionId(); const timestamp = new Date().toISOString(); const fileTimestamp = timestamp.replace(/[:.]/g, "-"); this.#sessionFile = path.join(this.getSessionDir(), `${fileTimestamp}_${this.#sessionId}.jsonl`); // Update the header with new ID but keep all entries const oldHeader = this.#fileEntries.find(e => e.type === "session") as SessionHeader | undefined; const newHeader: SessionHeader = { type: "session", version: CURRENT_SESSION_VERSION, id: this.#sessionId, title: oldHeader?.title ?? this.#sessionName, titleSource: oldHeader?.titleSource ?? this.#titleSource, timestamp, cwd: this.cwd, parentSession: oldSessionId, }; this.#sessionName = newHeader.title; this.#titleSource = newHeader.titleSource; // Replace the header in fileEntries const entries = this.#fileEntries.filter((e): e is SessionEntry => e.type !== "session"); this.#fileEntries = [newHeader, ...entries]; // Write the new session file this.#flushed = false; await this.#rewriteFile(); return { oldSessionFile, newSessionFile: this.#sessionFile }; } /** * Move the session to a new working directory. * Moves session files and artifacts on disk, updates all internal references, * and rewrites the session header with the new cwd. */ async moveTo(newCwd: string): Promise { const resolvedCwd = path.resolve(newCwd); if (resolvedCwd === this.cwd) return; const managedSessionsRoot = resolveManagedSessionRoot(this.sessionDir, this.cwd); const newSessionDir = managedSessionsRoot ? computeDefaultSessionDir(resolvedCwd, this.storage, managedSessionsRoot) : computeDefaultSessionDir(resolvedCwd, this.storage); let hadSessionFile = false; if (this.persist && this.#sessionFile) { // Close the persist writer before moving files await this.#closePersistWriter(); this.#persistChain = Promise.resolve(); this.#persistError = undefined; this.#persistErrorReported = false; const oldSessionFile = this.#sessionFile; const newSessionFile = path.join(newSessionDir, path.basename(oldSessionFile)); const oldArtifactDir = oldSessionFile.slice(0, -6); // strip .jsonl const newArtifactDir = newSessionFile.slice(0, -6); hadSessionFile = this.storage.existsSync(oldSessionFile); let movedSessionFile = false; let movedArtifactDir = false; try { // Guard: session file may not exist yet (no assistant messages persisted) if (hadSessionFile) { await fs.promises.rename(oldSessionFile, newSessionFile); movedSessionFile = true; } try { const stat = await fs.promises.stat(oldArtifactDir); if (stat.isDirectory()) { await fs.promises.rename(oldArtifactDir, newArtifactDir); movedArtifactDir = true; } } catch (err) { if (!isEnoent(err)) throw err; } } catch (err) { if (movedArtifactDir) { try { await fs.promises.rename(newArtifactDir, oldArtifactDir); } catch (rollbackErr) { throw new Error( `Failed to move artifacts and rollback: ${rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr)}`, ); } } if (movedSessionFile) { try { await fs.promises.rename(newSessionFile, oldSessionFile); } catch (rollbackErr) { throw new Error( `Failed to move session file and rollback: ${rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr)}`, ); } } throw err; } this.#sessionFile = newSessionFile; } // Update cwd and sessionDir after the move succeeds. this.cwd = resolvedCwd; this.sessionDir = newSessionDir; // Update the session header in fileEntries const header = this.#fileEntries.find(e => e.type === "session") as SessionHeader | undefined; if (header) { header.cwd = resolvedCwd; } // Rewrite the session file at its new location with updated header. // hadSessionFile: file existed before move → must rewrite to update cwd // hasAssistant: assistant messages in memory but file missing → recreate from memory // Neither true → fresh session, never written → preserve lazy-persist const hasAssistant = this.#fileEntries.some(e => e.type === "message" && e.message.role === "assistant"); if (this.persist && this.#sessionFile && (hadSessionFile || hasAssistant)) { await this.#rewriteFile(); } // Update terminal breadcrumb if (this.#sessionFile) { writeTerminalBreadcrumb(resolvedCwd, this.#sessionFile); } } /** Sync version for initial creation (no existing writer to close) */ #newSessionSync(options?: NewSessionOptions): string | undefined { this.#persistChain = Promise.resolve(); this.#persistError = undefined; this.#persistErrorReported = false; this.#sessionId = createSessionId(); this.#sessionName = undefined; this.#titleSource = undefined; 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.#byId.clear(); this.#labelsById.clear(); this.#leafId = null; this.#flushed = false; this.#needsFullRewriteOnNextPersist = false; this.#ensuredOnDisk = false; this.#usageStatistics = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, premiumRequests: 0, cost: 0 }; this.#inMemoryArtifacts = null; this.#inMemoryArtifactCounter = 0; if (this.persist) { const fileTimestamp = timestamp.replace(/[:.]/g, "-"); this.#sessionFile = path.join(this.getSessionDir(), `${fileTimestamp}_${this.#sessionId}.jsonl`); writeTerminalBreadcrumb(this.cwd, this.#sessionFile); } return this.#sessionFile; } #buildIndex(): void { this.#byId.clear(); this.#labelsById.clear(); this.#leafId = null; this.#usageStatistics = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, premiumRequests: 0, cost: 0 }; for (const entry of this.#fileEntries) { if (entry.type === "session") continue; this.#byId.set(entry.id, entry); this.#leafId = entry.id; if (entry.type === "label") { if (entry.label) { this.#labelsById.set(entry.targetId, entry.label); } else { this.#labelsById.delete(entry.targetId); } } if (entry.type === "message" && entry.message.role === "assistant") { const usage = entry.message.usage; this.#usageStatistics.input += usage.input; this.#usageStatistics.output += usage.output; this.#usageStatistics.cacheRead += usage.cacheRead; this.#usageStatistics.cacheWrite += usage.cacheWrite; this.#usageStatistics.premiumRequests += usage.premiumRequests ?? 0; this.#usageStatistics.cost += usage.cost.total; } if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.toolName === "task") { const usage = getTaskToolUsage(entry.message.details); if (usage) { this.#usageStatistics.input += usage.input; this.#usageStatistics.output += usage.output; this.#usageStatistics.cacheRead += usage.cacheRead; this.#usageStatistics.cacheWrite += usage.cacheWrite; this.#usageStatistics.premiumRequests += usage.premiumRequests ?? 0; this.#usageStatistics.cost += usage.cost.total; } } } } #recordPersistError(err: unknown): Error { const normalized = toError(err); if (!this.#persistError) this.#persistError = normalized; if (!this.#persistErrorReported) { this.#persistErrorReported = true; logger.error("Session persistence error.", { sessionFile: this.#sessionFile, error: normalized.message, stack: normalized.stack, }); } return normalized; } #queuePersistTask(task: () => Promise, options?: { ignoreError?: boolean }): Promise { const next = this.#persistChain.then(async () => { if (this.#persistError && !options?.ignoreError) throw this.#persistError; await task(); }); this.#persistChain = next.catch(err => { this.#recordPersistError(err); }); return next; } #ensurePersistWriter(): NdjsonFileWriter | undefined { if (!this.persist || !this.#sessionFile) return undefined; if (this.#persistError) throw this.#persistError; if (this.#persistWriter && this.#persistWriterPath === this.#sessionFile) { if (this.#persistWriter.isOpen()) return this.#persistWriter; // Cached writer for the current file is mid-close (queued // `#closePersistWriterInternal` has flipped `#closing` but not yet // cleared `#persistWriter`). Returning it would make `writeSync` // throw "Writer closed". Defer to the caller — `_persist` routes // the entry through the async rewrite path so it still lands on disk. return undefined; } // Note: caller must await _closePersistWriter() before calling this if switching files this.#persistWriter = new NdjsonFileWriter(this.storage, this.#sessionFile, { onError: err => { this.#recordPersistError(err); }, }); this.#persistWriterPath = this.#sessionFile; return this.#persistWriter; } async #closePersistWriterInternal(): Promise { if (this.#persistWriter) { await this.#persistWriter.close(); this.#persistWriter = undefined; } this.#persistWriterPath = undefined; } async #closePersistWriter(): Promise { await this.#queuePersistTask( async () => { await this.#closePersistWriterInternal(); }, { ignoreError: true }, ); } async #writeEntriesAtomically(entries: FileEntry[]): Promise { if (!this.#sessionFile) return; const dir = path.resolve(this.#sessionFile, ".."); const tempPath = path.join(dir, `.${path.basename(this.#sessionFile)}.${Snowflake.next()}.tmp`); const writer = new NdjsonFileWriter(this.storage, tempPath, { flags: "w" }); try { for (const entry of entries) { await writer.write(entry); } await writer.flush(); await writer.fsync(); await writer.close(); await this.storage.rename(tempPath, this.#sessionFile); } catch (err) { try { await writer.close(); } catch { // Ignore cleanup errors } try { await this.storage.unlink(tempPath); } catch { // Ignore cleanup errors } throw toError(err); } } async #rewriteFile(): Promise { if (!this.persist || !this.#sessionFile) return; await this.#queuePersistTask(async () => { await this.#closePersistWriterInternal(); const entries = await Promise.all( this.#fileEntries.map(entry => prepareEntryForPersistence(entry, this.#blobStore)), ); await this.#writeEntriesAtomically(entries); this.#needsFullRewriteOnNextPersist = false; this.#flushed = true; }); } isPersisted(): boolean { return this.persist; } /** * Force-persist all current entries to disk, even when no assistant message exists yet. * Used by ACP mode where session/new must create a discoverable session immediately. */ async ensureOnDisk(): Promise { if (!this.persist || !this.#sessionFile) return; if (this.#flushed && !this.#needsFullRewriteOnNextPersist) return; await this.#rewriteFile(); this.#ensuredOnDisk = true; } /** Flush pending writes to disk. Call before switching sessions or on shutdown. */ async flush(): Promise { await this.#queuePersistTask(async () => { if (this.#persistWriter) { await this.#persistWriter.flush(); await this.#persistWriter.fsync(); } }); if (this.#persistError) throw this.#persistError; } /** Close the persistent writer after flushing all pending data. */ async close(): Promise { if (!this.#persistWriter) return; await this.#queuePersistTask(async () => { await this.#closePersistWriterInternal(); this.#flushed = true; }); if (this.#persistError) throw this.#persistError; } getCwd(): string { return this.cwd; } /** Get usage statistics across all assistant messages in the session. */ getUsageStatistics(): UsageStatistics { return this.#usageStatistics; } getSessionDir(): string { return this.sessionDir; } getSessionId(): string { return this.#sessionId; } getSessionFile(): string | undefined { return this.#sessionFile; } /** * Returns the session artifacts directory path (session file path without .jsonl). * Returns null when the session is not persisted to a file. * When this session has adopted an external ArtifactManager (subagent case), * returns that manager's directory so reads/writes land in the shared parent * dir instead of a private (non-existent) subdir. */ getArtifactsDir(): string | null { if (this.#adoptedArtifactManager) return this.#adoptedArtifactManager.dir; const sessionFile = this.#sessionFile; return sessionFile ? sessionFile.slice(0, -6) : null; } /** * Adopt an externally-owned ArtifactManager. Used by subagents to share * the parent session's artifact directory and ID counter. */ adoptArtifactManager(manager: ArtifactManager): void { this.#adoptedArtifactManager = manager; } /** * Returns the ArtifactManager this session writes through. Lazily creates * one bound to the current session file unless an external manager was * adopted via `adoptArtifactManager`. Returns null only for non-persistent * sessions with no adopted manager. */ getArtifactManager(): ArtifactManager | null { return this.#getOrCreateArtifactManager(); } /** * Returns an artifact manager bound to the current session file. * Recreates the manager when the active session file changes. */ #getOrCreateArtifactManager(): ArtifactManager | null { if (this.#adoptedArtifactManager) return this.#adoptedArtifactManager; const sessionFile = this.#sessionFile; if (!sessionFile) { this.#artifactManager = null; this.#artifactManagerSessionFile = null; return null; } if (this.#artifactManager && this.#artifactManagerSessionFile === sessionFile) { return this.#artifactManager; } const manager = new ArtifactManager(sessionFile.slice(0, -6)); this.#artifactManager = manager; this.#artifactManagerSessionFile = sessionFile; return manager; } /** * Allocate a new artifact path and ID for the current session. * Returns an empty object when the session is not persisted. */ async allocateArtifactPath(toolType: string): Promise<{ id?: string; path?: string }> { const manager = this.#getOrCreateArtifactManager(); if (!manager) return {}; return manager.allocatePath(toolType); } /** * Save artifact content under the current session and return artifact ID. * Returns an artifact ID for all sessions (file-backed for persistent, in-memory fallback otherwise). */ async saveArtifact(content: string, toolType: string): Promise { const manager = this.#getOrCreateArtifactManager(); if (manager) return manager.save(content, toolType); // Non-persistent session: store in memory so spill truncation can proceed. if (!this.#inMemoryArtifacts) this.#inMemoryArtifacts = new Map(); const id = String(this.#inMemoryArtifactCounter++); this.#inMemoryArtifacts.set(id, content); return id; } /** * Resolve an artifact ID to an on-disk path for the current session. * Returns null when missing or when the session is not persisted. */ async getArtifactPath(id: string): Promise { const manager = this.#getOrCreateArtifactManager(); if (!manager) return null; return manager.getPath(id); } /** * Path to the unsent-input draft sidecar for the current session. Lives inside * the artifacts directory so it is removed together with the session on * `dropSession`. Returns null when the session has no on-disk identity. */ #getDraftPath(): string | null { const dir = this.getArtifactsDir(); return dir ? path.join(dir, "draft.txt") : null; } /** * Persist (or clear) the current editor draft so the next resume of this * session can restore it. Empty text deletes any stale draft. No-op when the * session is not persisted. */ async saveDraft(text: string): Promise { const draftPath = this.#getDraftPath(); if (!draftPath || !this.persist) return; if (text.length === 0) { try { await this.storage.unlink(draftPath); } catch (err) { if (!isEnoent(err)) throw err; } return; } // Force the session header onto disk so resume can find the file we are // attaching this draft to. Without this, a session whose first message // never produced an assistant reply would persist a draft next to a // session file that does not exist on disk. await this.ensureOnDisk(); await this.storage.writeText(draftPath, text); } /** * Read and remove the saved draft. Returns the previously-saved text, or * null when no draft is pending. Single-shot: a successful read removes the * sidecar so a subsequent resume does not re-restore the same text. */ async consumeDraft(): Promise { const draftPath = this.#getDraftPath(); if (!draftPath) return null; let text: string; try { text = await this.storage.readText(draftPath); } catch (err) { if (isEnoent(err)) return null; throw err; } try { await this.storage.unlink(draftPath); } catch (err) { if (!isEnoent(err)) throw err; } return text; } /** The source that set the session name: "user" (manual /rename or RPC) or "auto" (generated title). */ get titleSource(): "auto" | "user" | undefined { return this.#titleSource; } getSessionName(): string | undefined { return this.#sessionName; } /** Strip C0/C1 control characters (includes ESC, so removes ANSI sequences) and collapse whitespace. */ static #sanitizeName(name: string): string { return name .replace(/[\u0000-\u001f\u007f-\u009f]/g, " ") .replace(/ +/g, " ") .trim(); } /** * Set the session display name. * @param source - "user" for explicit renames (/rename command, RPC); "auto" for generated titles. * Auto-generated titles are silently ignored when the user has already set a name. */ async setSessionName(name: string, source: "auto" | "user" = "auto"): Promise { // User-set names take permanent precedence over auto-generated ones. if (this.#titleSource === "user" && source === "auto") return false; const sanitized = SessionManager.#sanitizeName(name); if (!sanitized) return false; this.#sessionName = sanitized; this.#titleSource = source; // Update the in-memory header (so first flush includes title) const header = this.#fileEntries.find(e => e.type === "session") as SessionHeader | undefined; if (header) { header.title = sanitized; header.titleSource = source; } // Update the session file header with the title (if already flushed) const sessionFile = this.#sessionFile; if (this.persist && sessionFile && this.storage.existsSync(sessionFile)) { await this.#rewriteFile(); } return true; } _persist(entry: SessionEntry): void { if (!this.persist || !this.#sessionFile) return; if (this.#persistError) throw this.#persistError; // Normally we wait for the first assistant message before persisting to avoid // creating files for sessions that never produce output. Once ensureOnDisk() has // been called, the session is already on disk and every entry must be flushed. if (!this.#ensuredOnDisk) { const hasAssistant = this.#fileEntries.some(e => e.type === "message" && e.message.role === "assistant"); if (!hasAssistant) { // Mark as not flushed so when assistant arrives, all entries get written. this.#flushed = false; return; } } if (this.#needsFullRewriteOnNextPersist || !this.#flushed) { // Cold path: rewrite the whole file atomically. Async — the writer is // closed/reopened and every entry is re-prepared. Errors flow through // `#persistChain` → `#recordPersistError`; we swallow the rejection // here to avoid an unhandled rejection when the persist dir races with // test-level tempDir cleanup. this.#rewriteFile().catch(() => {}); return; } // Hot path: synchronously truncate + append. `fs.writeSync` returns once the // bytes are in the kernel page cache, so the entry survives an OOM/SIGKILL // landing immediately after this call. Image externalization (rare) runs via // the synchronous blob-store path so blob bytes are durable before the JSONL // line referencing them is written. try { const writer = this.#ensurePersistWriter(); if (!writer) { // `#ensurePersistWriter` returns undefined here only when the cached // writer is mid-close (the `!persist`/`!sessionFile` cases are // rejected above). Route through `#rewriteFile` so the entry — which // is already in `#fileEntries` — persists once the close drains. this.#rewriteFile().catch(() => {}); return; } const persistedEntry = prepareEntryForPersistenceSync(entry, this.#blobStore); writer.writeSync(persistedEntry); } catch (err) { this.#recordPersistError(err); } } #appendEntry(entry: SessionEntry): void { this.#fileEntries.push(entry); this.#byId.set(entry.id, entry); this.#leafId = entry.id; this._persist(entry); if (entry.type === "message" && entry.message.role === "assistant") { const usage = entry.message.usage; this.#usageStatistics.input += usage.input; this.#usageStatistics.output += usage.output; this.#usageStatistics.cacheRead += usage.cacheRead; this.#usageStatistics.cacheWrite += usage.cacheWrite; this.#usageStatistics.premiumRequests += usage.premiumRequests ?? 0; this.#usageStatistics.cost += usage.cost.total; } if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.toolName === "task") { const usage = getTaskToolUsage(entry.message.details); if (usage) { this.#usageStatistics.input += usage.input; this.#usageStatistics.output += usage.output; this.#usageStatistics.cacheRead += usage.cacheRead; this.#usageStatistics.cacheWrite += usage.cacheWrite; this.#usageStatistics.premiumRequests += usage.premiumRequests ?? 0; this.#usageStatistics.cost += usage.cost.total; } } } /** Append a message as child of current leaf, then advance leaf. Returns entry id. * Does not allow writing CompactionSummaryMessage and BranchSummaryMessage directly. * Reason: we want these to be top-level entries in the session, not message session entries, * so it is easier to find them. * These need to be appended via appendCompaction() and appendBranchSummary() methods. */ appendMessage( message: | Message | CustomMessage | HookMessage | BashExecutionMessage | PythonExecutionMessage | FileMentionMessage, ): string { const entry: SessionMessageEntry = { type: "message", id: generateId(this.#byId), parentId: this.#leafId, timestamp: new Date().toISOString(), message, }; this.#appendEntry(entry); return entry.id; } /** Append a thinking level change as child of current leaf, then advance leaf. Returns entry id. */ appendThinkingLevelChange(thinkingLevel?: string): string { const entry: ThinkingLevelChangeEntry = { type: "thinking_level_change", id: generateId(this.#byId), parentId: this.#leafId, timestamp: new Date().toISOString(), thinkingLevel: thinkingLevel ?? null, }; this.#appendEntry(entry); return entry.id; } appendServiceTierChange(serviceTier: ServiceTier | null): string { const entry: ServiceTierChangeEntry = { type: "service_tier_change", id: generateId(this.#byId), parentId: this.#leafId, timestamp: new Date().toISOString(), serviceTier, }; this.#appendEntry(entry); return entry.id; } /** Append a mode change as child of current leaf, then advance leaf. Returns entry id. */ appendModeChange(mode: string, data?: Record): string { const entry: ModeChangeEntry = { type: "mode_change", id: generateId(this.#byId), parentId: this.#leafId, timestamp: new Date().toISOString(), mode, data, }; this.#appendEntry(entry); return entry.id; } /** * Append a model change as child of current leaf, then advance leaf. Returns entry id. * @param model Model in "provider/modelId" format * @param role Optional role (default: "default") */ appendModelChange(model: string, role?: string): string { const entry: ModelChangeEntry = { type: "model_change", id: generateId(this.#byId), parentId: this.#leafId, timestamp: new Date().toISOString(), model, role, }; this.#appendEntry(entry); return entry.id; } /** Append session init metadata (for subagent debugging/replay). Returns entry id. */ appendSessionInit(init: { systemPrompt: string; task: string; tools: string[]; outputSchema?: unknown }): string { const entry: SessionInitEntry = { type: "session_init", id: generateId(this.#byId), parentId: this.#leafId, timestamp: new Date().toISOString(), ...init, }; this.#appendEntry(entry); return entry.id; } /** Append a compaction summary as child of current leaf, then advance leaf. Returns entry id. */ appendCompaction( summary: string, shortSummary: string | undefined, firstKeptEntryId: string, tokensBefore: number, details?: T, fromExtension?: boolean, preserveData?: Record, ): string { const entry: CompactionEntry = { type: "compaction", id: generateId(this.#byId), parentId: this.#leafId, timestamp: new Date().toISOString(), summary, shortSummary, firstKeptEntryId, tokensBefore, details, fromExtension, preserveData, }; this.#appendEntry(entry); return entry.id; } /** Append a custom entry (for extensions) as child of current leaf, then advance leaf. Returns entry id. */ appendCustomEntry(customType: string, data?: unknown): string { const entry: CustomEntry = { type: "custom", customType, data, id: generateId(this.#byId), parentId: this.#leafId, timestamp: new Date().toISOString(), }; this.#appendEntry(entry); return entry.id; } /** * Rewrite the session file after in-place entry updates. * Use sparingly (e.g., pruning old tool outputs). */ async rewriteEntries(): Promise { if (!this.persist || !this.#sessionFile) return; await this.#rewriteFile(); } /** * Append a custom message entry (for extensions) that participates in LLM context. * @param customType Hook identifier for filtering on reload * @param content Message content (string or TextContent/ImageContent array) * @param display Whether to show in TUI (true = styled display, false = hidden) * @param details Optional extension-specific metadata (not sent to LLM) * @param attribution Who initiated this message for billing/attribution semantics * @returns Entry id */ appendCustomMessageEntry( customType: string, content: string | (TextContent | ImageContent)[], display: boolean, details?: T, attribution: MessageAttribution = "agent", ): string { const entry: CustomMessageEntry = { type: "custom_message", customType, content, display, // Drop AgentSession-internal transient fields (allowlist in // `INTERNAL_DETAILS_FIELDS`) before disk persistence. Single // chokepoint covers every CustomMessage write path. details: stripInternalDetailsFields(details), attribution, id: generateId(this.#byId), parentId: this.#leafId, timestamp: new Date().toISOString(), }; this.#appendEntry(entry); return entry.id; } // ========================================================================= // TTSR (Time Traveling Stream Rules) // ========================================================================= /** * Append an MCP tool selection entry recording the discovery-selected MCP tools. * @param selectedToolNames MCP tool names selected for this branch * @returns Entry id */ appendMCPToolSelection(selectedToolNames: string[]): string { const entry: MCPToolSelectionEntry = { type: "mcp_tool_selection", id: generateId(this.#byId), parentId: this.#leafId, timestamp: new Date().toISOString(), selectedToolNames: [...selectedToolNames], }; this.#appendEntry(entry); return entry.id; } /** * Append a TTSR injection entry recording which rules were injected. * @param ruleNames Names of rules that were injected * @returns Entry id */ appendTtsrInjection(ruleNames: string[]): string { const entry: TtsrInjectionEntry = { type: "ttsr_injection", id: generateId(this.#byId), parentId: this.#leafId, timestamp: new Date().toISOString(), injectedRules: ruleNames, }; this.#appendEntry(entry); return entry.id; } /** * Get all unique TTSR rule names that have been injected in the current branch. * Scans from root to current leaf for ttsr_injection entries. */ getInjectedTtsrRules(): string[] { const path = this.getBranch(); const ruleNames = new Set(); for (const entry of path) { if (entry.type === "ttsr_injection") { for (const name of entry.injectedRules) { ruleNames.add(name); } } } return Array.from(ruleNames); } // ========================================================================= // Tree Traversal // ========================================================================= getLeafId(): string | null { return this.#leafId; } getLeafEntry(): SessionEntry | undefined { return this.#leafId ? this.#byId.get(this.#leafId) : undefined; } /** * Get the most recent model role from the current session path. * Returns undefined if no model change has been recorded. */ getLastModelChangeRole(): string | undefined { let current = this.getLeafEntry(); while (current) { if (current.type === "model_change") { return current.role ?? "default"; } current = current.parentId ? this.#byId.get(current.parentId) : undefined; } return undefined; } getEntry(id: string): SessionEntry | undefined { return this.#byId.get(id); } /** * Get all direct children of an entry. */ getChildren(parentId: string): SessionEntry[] { const children: SessionEntry[] = []; for (const entry of this.#byId.values()) { if (entry.parentId === parentId) { children.push(entry); } } return children; } /** * Get the label for an entry, if any. */ getLabel(id: string): string | undefined { return this.#labelsById.get(id); } /** * Set or clear a label on an entry. * Labels are user-defined markers for bookmarking/navigation. * Pass undefined or empty string to clear the label. */ appendLabelChange(targetId: string, label: string | undefined): string { if (!this.#byId.has(targetId)) { throw new Error(`Entry ${targetId} not found`); } const entry: LabelEntry = { type: "label", id: generateId(this.#byId), parentId: this.#leafId, timestamp: new Date().toISOString(), targetId, label, }; this.#appendEntry(entry); if (label) { this.#labelsById.set(targetId, label); } else { this.#labelsById.delete(targetId); } return entry.id; } /** * Walk from entry to root, returning all entries in path order. * Includes all entry types (messages, compaction, model changes, etc.). * Use buildSessionContext() to get the resolved messages for the LLM. */ getBranch(fromId?: string): SessionEntry[] { const path: SessionEntry[] = []; const startId = fromId ?? this.#leafId; let current = startId ? this.#byId.get(startId) : undefined; while (current) { path.unshift(current); current = current.parentId ? this.#byId.get(current.parentId) : undefined; } return path; } /** * Build the session context (what gets sent to the LLM). * Uses tree traversal from current leaf. */ buildSessionContext(): SessionContext { return buildSessionContext(this.getEntries(), this.#leafId, this.#byId); } /** Strip stale OpenAI Responses assistant replay metadata from loaded in-memory entries. */ sanitizeLoadedOpenAIResponsesReplayMetadata(): boolean { let didSanitize = false; for (const entry of this.#fileEntries) { if (entry.type !== "message" || entry.message.role !== "assistant") { continue; } const sanitizedMessage = sanitizeRehydratedOpenAIResponsesAssistantMessage(entry.message); if (sanitizedMessage === entry.message) { continue; } entry.message = sanitizedMessage; didSanitize = true; } return didSanitize; } /** * Get session header. */ getHeader(): SessionHeader | null { const h = this.#fileEntries.find(e => e.type === "session"); return h ? (h as SessionHeader) : null; } /** * Get all session entries (excludes header). Returns a shallow copy. * The session is append-only: use appendXXX() to add entries, branch() to * change the leaf pointer. Entries cannot be modified or deleted. */ getEntries(): SessionEntry[] { return this.#fileEntries.filter((e): e is SessionEntry => e.type !== "session"); } /** * Get the session as a tree structure. Returns a shallow defensive copy of all entries. * A well-formed session has exactly one root (first entry with parentId === null). * Orphaned entries (broken parent chain) are also returned as roots. */ getTree(): SessionTreeNode[] { const entries = this.getEntries(); const nodeMap = new Map(); const roots: SessionTreeNode[] = []; // Create nodes with resolved labels for (const entry of entries) { const label = this.#labelsById.get(entry.id); nodeMap.set(entry.id, { entry, children: [], label }); } // Build tree for (const entry of entries) { const node = nodeMap.get(entry.id)!; if (entry.parentId === null || entry.parentId === entry.id) { roots.push(node); } else { const parent = nodeMap.get(entry.parentId); if (parent) { parent.children.push(node); } else { // Orphan - treat as root roots.push(node); } } } // Sort children by timestamp (oldest first, newest at bottom) // Use iterative approach to avoid stack overflow on deep trees const stack: SessionTreeNode[] = [...roots]; while (stack.length > 0) { const node = stack.pop()!; node.children.sort((a, b) => new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime()); stack.push(...node.children); } return roots; } // ========================================================================= // Branching // ========================================================================= /** * Start a new branch from an earlier entry. * Moves the leaf pointer to the specified entry. The next appendXXX() call * will create a child of that entry, forming a new branch. Existing entries * are not modified or deleted. */ branch(branchFromId: string): void { if (!this.#byId.has(branchFromId)) { throw new Error(`Entry ${branchFromId} not found`); } this.#leafId = branchFromId; } /** * Reset the leaf pointer to null (before any entries). * The next appendXXX() call will create a new root entry (parentId = null). * Use this when navigating to re-edit the first user message. */ resetLeaf(): void { this.#leafId = null; } /** * Start a new branch with a summary of the abandoned path. * Same as branch(), but also appends a branch_summary entry that captures * context from the abandoned conversation path. */ branchWithSummary(branchFromId: string | null, summary: string, details?: unknown, fromExtension?: boolean): string { if (branchFromId !== null && !this.#byId.has(branchFromId)) { throw new Error(`Entry ${branchFromId} not found`); } this.#leafId = branchFromId; const entry: BranchSummaryEntry = { type: "branch_summary", id: generateId(this.#byId), parentId: branchFromId, timestamp: new Date().toISOString(), fromId: branchFromId ?? "root", summary, details, fromExtension, }; this.#appendEntry(entry); return entry.id; } /** * Create a new session file containing only the path from root to the specified leaf. * Useful for extracting a single conversation path from a branched session. * Returns the new session file path, or undefined if not persisting. */ createBranchedSession(leafId: string): string | undefined { const previousSessionFile = this.#sessionFile; const branchPath = this.getBranch(leafId); if (branchPath.length === 0) { throw new Error(`Entry ${leafId} not found`); } // Filter out LabelEntry from path - we'll recreate them from the resolved map const pathWithoutLabels = branchPath.filter(e => e.type !== "label"); const newSessionId = createSessionId(); const timestamp = new Date().toISOString(); const fileTimestamp = timestamp.replace(/[:.]/g, "-"); const newSessionFile = path.join(this.getSessionDir(), `${fileTimestamp}_${newSessionId}.jsonl`); const header: SessionHeader = { type: "session", version: CURRENT_SESSION_VERSION, id: newSessionId, timestamp, cwd: this.cwd, parentSession: this.persist ? previousSessionFile : undefined, }; // Collect labels for entries in the path const pathEntryIds = new Set(pathWithoutLabels.map(e => e.id)); const labelsToWrite: Array<{ targetId: string; label: string }> = []; for (const [targetId, label] of this.#labelsById) { if (pathEntryIds.has(targetId)) { labelsToWrite.push({ targetId, label }); } } if (this.persist) { const lines: string[] = []; lines.push(JSON.stringify(header)); for (const entry of pathWithoutLabels) { lines.push(JSON.stringify(entry)); } // Write fresh label entries at the end const lastEntryId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null; let parentId = lastEntryId; const labelEntries: LabelEntry[] = []; for (const { targetId, label } of labelsToWrite) { const labelEntry: LabelEntry = { type: "label", id: generateId(new Set(pathEntryIds)), parentId, timestamp: new Date().toISOString(), targetId, label, }; lines.push(JSON.stringify(labelEntry)); pathEntryIds.add(labelEntry.id); labelEntries.push(labelEntry); parentId = labelEntry.id; } this.storage.writeTextSync(newSessionFile, `${lines.join("\n")}\n`); this.#fileEntries = [header, ...pathWithoutLabels, ...labelEntries]; this.#sessionId = newSessionId; this.#sessionFile = newSessionFile; this.#flushed = true; this.#buildIndex(); return newSessionFile; } // In-memory mode: replace current session with the path + labels const labelEntries: LabelEntry[] = []; let parentId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null; for (const { targetId, label } of labelsToWrite) { const labelEntry: LabelEntry = { type: "label", id: generateId(new Set([...pathEntryIds, ...labelEntries.map(e => e.id)])), parentId, timestamp: new Date().toISOString(), targetId, label, }; labelEntries.push(labelEntry); parentId = labelEntry.id; } this.#fileEntries = [header, ...pathWithoutLabels, ...labelEntries]; this.#sessionId = newSessionId; this.#buildIndex(); return undefined; } /** * Resolve the canonical default session directory for a cwd. */ static getDefaultSessionDir( cwd: string, agentDir?: string, storage: SessionStorage = new FileSessionStorage(), ): string { return computeDefaultSessionDir(cwd, storage, getSessionsDir(agentDir)); } /** * Create a new session. * @param cwd Working directory (stored in session header) * @param sessionDir Optional session directory. If omitted, uses default (~/.omp/agent/sessions//). */ static create(cwd: string, sessionDir?: string, storage: SessionStorage = new FileSessionStorage()): SessionManager { const dir = sessionDir ?? SessionManager.getDefaultSessionDir(cwd, undefined, storage); const manager = new SessionManager(cwd, dir, true, storage); manager.#initNewSession(); return manager; } /** * Fork a session into the current project directory. * Copies history from another session file while creating a new session file in the current sessionDir. */ static async forkFrom( sourcePath: string, cwd: string, sessionDir?: string, storage: SessionStorage = new FileSessionStorage(), ): Promise { const dir = sessionDir ?? SessionManager.getDefaultSessionDir(cwd, undefined, storage); const manager = new SessionManager(cwd, dir, true, storage); const forkEntries = structuredClone(await loadEntriesFromFile(sourcePath, storage)) as FileEntry[]; migrateToCurrentVersion(forkEntries); await resolveBlobRefsInEntries(forkEntries, manager.#blobStore); const sourceHeader = forkEntries.find(e => e.type === "session") as SessionHeader | undefined; const historyEntries = forkEntries.filter(entry => entry.type !== "session") as SessionEntry[]; manager.#newSessionSync({ parentSession: sourceHeader?.id }); const newHeader = manager.#fileEntries[0] as SessionHeader; newHeader.title = sourceHeader?.title; newHeader.titleSource = sourceHeader?.titleSource; manager.#fileEntries = [newHeader, ...historyEntries]; manager.#sessionName = newHeader.title; manager.#titleSource = newHeader.titleSource; manager.sanitizeLoadedOpenAIResponsesReplayMetadata(); manager.#buildIndex(); await manager.#rewriteFile(); return manager; } /** * Open a specific session file. * @param path Path to session file * @param sessionDir Optional session directory for /new or /branch. If omitted, derives from file's parent. */ static async open( filePath: string, sessionDir?: string, storage: SessionStorage = new FileSessionStorage(), ): Promise { // Extract cwd from session header if possible, otherwise use getProjectDir() const entries = await loadEntriesFromFile(filePath, storage); const header = entries.find(e => e.type === "session") as SessionHeader | undefined; const cwd = header?.cwd ?? getProjectDir(); // If no sessionDir provided, derive from file's parent directory const dir = sessionDir ?? path.resolve(filePath, ".."); const manager = new SessionManager(cwd, dir, true, storage); await manager.#initSessionFile(filePath); return manager; } /** * Continue the most recent session, or create new if none. * @param cwd Working directory * @param sessionDir Optional session directory. If omitted, uses default (~/.omp/agent/sessions//). */ static async continueRecent( cwd: string, sessionDir?: string, storage: SessionStorage = new FileSessionStorage(), ): Promise { const dir = sessionDir ?? SessionManager.getDefaultSessionDir(cwd, undefined, storage); // Prefer terminal-scoped breadcrumb (handles concurrent sessions correctly) const terminalSession = await readTerminalBreadcrumb(cwd); const mostRecent = terminalSession ?? (await findMostRecentSession(dir, storage)); const manager = new SessionManager(cwd, dir, true, storage); if (mostRecent) { await manager.#initSessionFile(mostRecent); } else { manager.#initNewSession(); } return manager; } /** Create an in-memory session (no file persistence) */ static inMemory( cwd: string = getProjectDir(), storage: SessionStorage = new MemorySessionStorage(), ): SessionManager { const manager = new SessionManager(cwd, "", false, storage); manager.#initNewSession(); return manager; } /** * List all sessions. * @param cwd Working directory (used to compute default session directory) * @param sessionDir Optional session directory. If omitted, uses default (~/.omp/agent/sessions//). */ static async list( cwd: string, sessionDir?: string, storage: SessionStorage = new FileSessionStorage(), ): Promise { const dir = sessionDir ?? SessionManager.getDefaultSessionDir(cwd, undefined, storage); try { const files = storage.listFilesSync(dir, "*.jsonl"); return await collectSessionsFromFiles(files, storage); } catch { return []; } } /** * List all sessions across all project directories. */ static async listAll(storage: SessionStorage = new FileSessionStorage()): Promise { const sessionsRoot = path.join(getDefaultAgentDir(), "sessions"); try { const files = await Array.fromAsync(new Bun.Glob("*/*.jsonl").scan(sessionsRoot), name => path.join(sessionsRoot, name), ); return await collectSessionsFromFiles(files, storage); } catch { return []; } } }