@caupulican/pi-agent-core 0.81.39 → 0.81.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +10 -5
  2. package/dist/agent-loop.d.ts.map +1 -1
  3. package/dist/agent-loop.js +77 -89
  4. package/dist/agent-loop.js.map +1 -1
  5. package/dist/compaction/branch-summarization.d.ts +4 -2
  6. package/dist/compaction/branch-summarization.d.ts.map +1 -1
  7. package/dist/compaction/branch-summarization.js +4 -0
  8. package/dist/compaction/branch-summarization.js.map +1 -1
  9. package/dist/compaction/compaction.d.ts +32 -1
  10. package/dist/compaction/compaction.d.ts.map +1 -1
  11. package/dist/compaction/compaction.js +172 -101
  12. package/dist/compaction/compaction.js.map +1 -1
  13. package/dist/compaction/extraction.d.ts.map +1 -1
  14. package/dist/compaction/extraction.js +1 -1
  15. package/dist/compaction/extraction.js.map +1 -1
  16. package/dist/compaction/loop.d.ts +1 -1
  17. package/dist/compaction/loop.d.ts.map +1 -1
  18. package/dist/compaction/loop.js +24 -7
  19. package/dist/compaction/loop.js.map +1 -1
  20. package/dist/compaction/utils.d.ts.map +1 -1
  21. package/dist/compaction/utils.js +3 -1
  22. package/dist/compaction/utils.js.map +1 -1
  23. package/dist/compaction/verification.d.ts +11 -0
  24. package/dist/compaction/verification.d.ts.map +1 -1
  25. package/dist/compaction/verification.js +93 -11
  26. package/dist/compaction/verification.js.map +1 -1
  27. package/dist/index.d.ts +2 -0
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +3 -0
  30. package/dist/index.js.map +1 -1
  31. package/dist/proxy.d.ts +1 -1
  32. package/dist/proxy.d.ts.map +1 -1
  33. package/dist/proxy.js +1 -0
  34. package/dist/proxy.js.map +1 -1
  35. package/dist/reliability/classifier.d.ts.map +1 -1
  36. package/dist/reliability/classifier.js +3 -3
  37. package/dist/reliability/classifier.js.map +1 -1
  38. package/dist/session/session-manager.d.ts +18 -6
  39. package/dist/session/session-manager.d.ts.map +1 -1
  40. package/dist/session/session-manager.js +68 -61
  41. package/dist/session/session-manager.js.map +1 -1
  42. package/dist/tool-failure-memory.d.ts +35 -0
  43. package/dist/tool-failure-memory.d.ts.map +1 -0
  44. package/dist/tool-failure-memory.js +290 -0
  45. package/dist/tool-failure-memory.js.map +1 -0
  46. package/dist/types.d.ts +5 -1
  47. package/dist/types.d.ts.map +1 -1
  48. package/dist/types.js.map +1 -1
  49. package/dist/usage.d.ts +5 -0
  50. package/dist/usage.d.ts.map +1 -1
  51. package/dist/usage.js +32 -0
  52. package/dist/usage.js.map +1 -1
  53. package/dist/uuid.d.ts +1 -1
  54. package/dist/uuid.d.ts.map +1 -1
  55. package/dist/uuid.js +1 -49
  56. package/dist/uuid.js.map +1 -1
  57. package/package.json +2 -2
@@ -1 +1 @@
1
- {"version":3,"file":"session-manager.d.ts","sourceRoot":"","sources":["../../src/session/session-manager.ts"],"names":[],"mappings":"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","sourcesContent":["import { randomUUID } from \"node:crypto\";\nimport {\n\tappendFileSync,\n\tcloseSync,\n\tcreateReadStream,\n\texistsSync,\n\tmkdirSync,\n\topenSync,\n\treaddirSync,\n\treadSync,\n\trenameSync,\n\tstatSync,\n\twriteFileSync,\n} from \"node:fs\";\nimport { readdir, stat } from \"node:fs/promises\";\nimport { join, resolve } from \"node:path\";\nimport { createInterface } from \"node:readline\";\nimport { StringDecoder } from \"node:string_decoder\";\nimport type { ImageContent, Message, TextContent } from \"@caupulican/pi-ai\";\nimport {\n\ttype BashExecutionMessage,\n\ttype CustomMessage,\n\tcreateBranchSummaryMessage,\n\tcreateCompactionSummaryMessage,\n\tcreateCustomMessage,\n} from \"../messages.ts\";\nimport type { AgentMessage } from \"../types.ts\";\nimport { normalizePath, resolvePath } from \"../utils/paths.ts\";\nimport { StreamingLineDecoder } from \"../utils/streaming-lines.ts\";\nimport { uuidv7 } from \"../uuid.ts\";\nimport { compactToolResultDetailsForRetention } from \"./message-retention.ts\";\n\nexport const CURRENT_SESSION_VERSION = 3;\n\nexport interface SessionHeader {\n\ttype: \"session\";\n\tversion?: number; // v1 sessions don't have this\n\tid: string;\n\ttimestamp: string;\n\tcwd: string;\n\tparentSession?: string;\n}\n\nexport interface NewSessionOptions {\n\tid?: string;\n\tparentSession?: string;\n}\n\nexport interface SessionEntryBase {\n\ttype: string;\n\tid: string;\n\tparentId: string | null;\n\ttimestamp: string;\n}\n\nexport interface SessionMessageEntry extends SessionEntryBase {\n\ttype: \"message\";\n\tmessage: AgentMessage;\n}\n\nexport interface ThinkingLevelChangeEntry extends SessionEntryBase {\n\ttype: \"thinking_level_change\";\n\tthinkingLevel: string;\n}\n\nexport interface ModelChangeEntry extends SessionEntryBase {\n\ttype: \"model_change\";\n\tprovider: string;\n\tmodelId: string;\n}\n\nexport interface CompactionEntry<T = unknown> extends SessionEntryBase {\n\ttype: \"compaction\";\n\tsummary: string;\n\tfirstKeptEntryId: string;\n\ttokensBefore: number;\n\t/** Extension-specific data (e.g., ArtifactIndex, version markers for structured compaction) */\n\tdetails?: T;\n\t/** True if generated by an extension, undefined/false if pi-generated (backward compatible) */\n\tfromHook?: boolean;\n}\n\nexport interface BranchSummaryEntry<T = unknown> extends SessionEntryBase {\n\ttype: \"branch_summary\";\n\tfromId: string;\n\tsummary: string;\n\t/** Extension-specific data (not sent to LLM) */\n\tdetails?: T;\n\t/** True if generated by an extension, false if pi-generated */\n\tfromHook?: boolean;\n}\n\n/**\n * Custom entry for extensions to store extension-specific data in the session.\n * Use customType to identify your extension's entries.\n *\n * Purpose: Persist extension state across session reloads. On reload, extensions can\n * scan entries for their customType and reconstruct internal state.\n *\n * Does NOT participate in LLM context (ignored by buildSessionContext).\n * For injecting content into context, see CustomMessageEntry.\n */\nexport interface CustomEntry<T = unknown> extends SessionEntryBase {\n\ttype: \"custom\";\n\tcustomType: string;\n\tdata?: T;\n}\n\n/** Label entry for user-defined bookmarks/markers on entries. */\nexport interface LabelEntry extends SessionEntryBase {\n\ttype: \"label\";\n\ttargetId: string;\n\tlabel: string | undefined;\n}\n\n/** Session metadata entry (e.g., user-defined display name). */\nexport interface SessionInfoEntry extends SessionEntryBase {\n\ttype: \"session_info\";\n\tname?: string;\n}\n\n/**\n * Custom message entry for extensions to inject messages into LLM context.\n * Use customType to identify your extension's entries.\n *\n * Unlike CustomEntry, this DOES participate in LLM context.\n * The content is converted to a user message in buildSessionContext().\n * Use details for extension-specific metadata (not sent to LLM).\n *\n * display controls TUI rendering:\n * - false: hidden entirely\n * - true: rendered with distinct styling (different from user messages)\n */\nexport interface CustomMessageEntry<T = unknown> extends SessionEntryBase {\n\ttype: \"custom_message\";\n\tcustomType: string;\n\tcontent: string | (TextContent | ImageContent)[];\n\tdetails?: T;\n\tdisplay: boolean;\n}\n\n/** Session entry - has id/parentId for tree structure (returned by \"read\" methods in SessionManager) */\nexport type SessionEntry =\n\t| SessionMessageEntry\n\t| ThinkingLevelChangeEntry\n\t| ModelChangeEntry\n\t| CompactionEntry\n\t| BranchSummaryEntry\n\t| CustomEntry\n\t| CustomMessageEntry\n\t| LabelEntry\n\t| SessionInfoEntry;\n\n/** Raw file entry (includes header) */\nexport type FileEntry = SessionHeader | SessionEntry;\n\n/** Tree node for getTree() - defensive copy of session structure */\nexport interface SessionTreeNode {\n\tentry: SessionEntry;\n\tchildren: SessionTreeNode[];\n\t/** Resolved label for this entry, if any */\n\tlabel?: string;\n\t/** Timestamp of the latest label change for this entry, if any */\n\tlabelTimestamp?: string;\n}\n\nexport interface SessionContext {\n\tmessages: AgentMessage[];\n\tthinkingLevel: string;\n\tmodel: { provider: string; modelId: string } | null;\n}\n\nexport interface SessionInfo {\n\tpath: string;\n\tid: string;\n\t/** Working directory where the session was started. Empty string for old sessions. */\n\tcwd: string;\n\t/** User-defined display name from session_info entries. */\n\tname?: string;\n\t/** Path to the parent session (if this session was forked). */\n\tparentSessionPath?: string;\n\tcreated: Date;\n\tmodified: Date;\n\tmessageCount: number;\n\tfirstMessage: string;\n\tallMessagesText: string;\n}\n\nexport type ReadonlySessionManager = Pick<\n\tSessionManager,\n\t| \"getCwd\"\n\t| \"getSessionDir\"\n\t| \"getSessionId\"\n\t| \"getSessionFile\"\n\t| \"getLeafId\"\n\t| \"getLeafEntry\"\n\t| \"getEntry\"\n\t| \"getLabel\"\n\t| \"getBranch\"\n\t| \"getLatestCustomEntryOnBranch\"\n\t| \"getHeader\"\n\t| \"getEntries\"\n\t| \"getTree\"\n\t| \"getSessionName\"\n>;\n\nfunction createSessionId(): string {\n\treturn uuidv7();\n}\n\nexport function assertValidSessionId(id: string): void {\n\tif (!/^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/.test(id)) {\n\t\tthrow new Error(\n\t\t\t\"Session id must be non-empty, contain only alphanumeric characters, '-', '_', and '.', and start and end with an alphanumeric character\",\n\t\t);\n\t}\n}\n\nexport function isAutoLearnSessionId(id: string): boolean {\n\treturn id.startsWith(\"auto-learn-\");\n}\n\n/** Generate a unique short ID (8 hex chars, collision-checked) */\nfunction generateId(byId: { has(id: string): boolean }): string {\n\tfor (let i = 0; i < 100; i++) {\n\t\tconst id = randomUUID().slice(0, 8);\n\t\tif (!byId.has(id)) return id;\n\t}\n\t// Fallback to full UUID if somehow we have collisions\n\treturn randomUUID();\n}\n\n/** Migrate v1 → v2: add id/parentId tree structure. Mutates in place. */\nfunction migrateV1ToV2(entries: FileEntry[]): void {\n\tconst ids = new Set<string>();\n\tlet prevId: string | null = null;\n\n\tfor (const entry of entries) {\n\t\tif (entry.type === \"session\") {\n\t\t\tentry.version = 2;\n\t\t\tcontinue;\n\t\t}\n\n\t\tentry.id = generateId(ids);\n\t\tids.add(entry.id);\n\t\tentry.parentId = prevId;\n\t\tprevId = entry.id;\n\n\t\t// Convert firstKeptEntryIndex to firstKeptEntryId for compaction\n\t\tif (entry.type === \"compaction\") {\n\t\t\tconst comp = entry as CompactionEntry & { firstKeptEntryIndex?: number };\n\t\t\tif (typeof comp.firstKeptEntryIndex === \"number\") {\n\t\t\t\tconst targetEntry = entries[comp.firstKeptEntryIndex];\n\t\t\t\tif (targetEntry && targetEntry.type !== \"session\") {\n\t\t\t\t\tcomp.firstKeptEntryId = targetEntry.id;\n\t\t\t\t}\n\t\t\t\tdelete comp.firstKeptEntryIndex;\n\t\t\t}\n\t\t}\n\t}\n}\n\n/** Migrate v2 → v3: rename hookMessage role to custom. Mutates in place. */\nfunction migrateV2ToV3(entries: FileEntry[]): void {\n\tfor (const entry of entries) {\n\t\tif (entry.type === \"session\") {\n\t\t\tentry.version = 3;\n\t\t\tcontinue;\n\t\t}\n\n\t\t// Update message entries with hookMessage role\n\t\tif (entry.type === \"message\") {\n\t\t\tconst msgEntry = entry as SessionMessageEntry;\n\t\t\tif (msgEntry.message && (msgEntry.message as { role: string }).role === \"hookMessage\") {\n\t\t\t\t(msgEntry.message as { role: string }).role = \"custom\";\n\t\t\t}\n\t\t}\n\t}\n}\n\n/**\n * Run all necessary migrations to bring entries to current version.\n * Mutates entries in place. Returns true if any migration was applied.\n */\nfunction migrateToCurrentVersion(entries: FileEntry[]): boolean {\n\tconst header = entries.find((e) => e.type === \"session\") as SessionHeader | undefined;\n\tconst version = header?.version ?? 1;\n\n\tif (version >= CURRENT_SESSION_VERSION) return false;\n\n\tif (version < 2) migrateV1ToV2(entries);\n\tif (version < 3) migrateV2ToV3(entries);\n\n\treturn true;\n}\n\n/** Exported for testing */\nexport function migrateSessionEntries(entries: FileEntry[]): void {\n\tmigrateToCurrentVersion(entries);\n}\n\n/** Exported for compaction.test.ts */\nexport function parseSessionEntries(content: string): FileEntry[] {\n\tconst entries: FileEntry[] = [];\n\tconst lines = content.trim().split(\"\\n\");\n\n\tfor (const line of lines) {\n\t\tif (!line.trim()) continue;\n\t\ttry {\n\t\t\tconst entry = JSON.parse(line) as FileEntry;\n\t\t\tentries.push(entry);\n\t\t} catch {\n\t\t\t// Skip malformed lines\n\t\t}\n\t}\n\n\treturn entries;\n}\n\nexport function getLatestCompactionEntry(entries: SessionEntry[]): CompactionEntry | null {\n\tfor (let i = entries.length - 1; i >= 0; i--) {\n\t\tif (entries[i].type === \"compaction\") {\n\t\t\treturn entries[i] as CompactionEntry;\n\t\t}\n\t}\n\treturn null;\n}\n\n/**\n * Build the session context from entries using tree traversal.\n * If leafId is provided, walks from that entry to root.\n * Handles compaction and branch summaries along the path.\n */\nexport function buildSessionContext(\n\tentries: SessionEntry[],\n\tleafId?: string | null,\n\tbyId?: Map<string, SessionEntry>,\n): SessionContext {\n\t// Build uuid index if not available\n\tif (!byId) {\n\t\tbyId = new Map<string, SessionEntry>();\n\t\tfor (const entry of entries) {\n\t\t\tbyId.set(entry.id, entry);\n\t\t}\n\t}\n\n\t// Find leaf\n\tlet leaf: SessionEntry | undefined;\n\tif (leafId === null) {\n\t\t// Explicitly null - return no messages (navigated to before first entry)\n\t\treturn { messages: [], thinkingLevel: \"off\", model: null };\n\t}\n\tif (leafId) {\n\t\tleaf = byId.get(leafId);\n\t}\n\tif (!leaf) {\n\t\t// Fallback to last entry (when leafId is undefined)\n\t\tleaf = entries[entries.length - 1];\n\t}\n\n\tif (!leaf) {\n\t\treturn { messages: [], thinkingLevel: \"off\", model: null };\n\t}\n\n\t// Walk from leaf to root, then reverse once. Repeated front insertion makes long branches quadratic.\n\tconst path: SessionEntry[] = [];\n\tlet current: SessionEntry | undefined = leaf;\n\twhile (current) {\n\t\tpath.push(current);\n\t\tcurrent = current.parentId ? byId.get(current.parentId) : undefined;\n\t}\n\tpath.reverse();\n\n\t// Extract settings and find compaction\n\tlet thinkingLevel = \"off\";\n\tlet model: { provider: string; modelId: string } | null = null;\n\tlet compaction: CompactionEntry | null = null;\n\n\tfor (const entry of path) {\n\t\tif (entry.type === \"thinking_level_change\") {\n\t\t\tthinkingLevel = entry.thinkingLevel;\n\t\t} else if (entry.type === \"model_change\") {\n\t\t\tmodel = { provider: entry.provider, modelId: entry.modelId };\n\t\t} else if (entry.type === \"message\" && entry.message.role === \"assistant\") {\n\t\t\tmodel = { provider: entry.message.provider, modelId: entry.message.model };\n\t\t} else if (entry.type === \"compaction\") {\n\t\t\tcompaction = entry;\n\t\t}\n\t}\n\n\t// Build messages and collect corresponding entries\n\t// When there's a compaction, we need to:\n\t// 1. Emit summary first (entry = compaction)\n\t// 2. Emit kept messages (from firstKeptEntryId up to compaction)\n\t// 3. Emit messages after compaction\n\tconst messages: AgentMessage[] = [];\n\n\tconst appendMessage = (entry: SessionEntry) => {\n\t\tif (entry.type === \"message\") {\n\t\t\tmessages.push(entry.message);\n\t\t} else if (entry.type === \"custom_message\") {\n\t\t\tmessages.push(\n\t\t\t\tcreateCustomMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp),\n\t\t\t);\n\t\t} else if (entry.type === \"branch_summary\" && entry.summary) {\n\t\t\tmessages.push(createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp));\n\t\t}\n\t};\n\n\tif (compaction) {\n\t\t// Emit summary first\n\t\tmessages.push(createCompactionSummaryMessage(compaction.summary, compaction.tokensBefore, compaction.timestamp));\n\n\t\t// Find compaction index in path\n\t\tconst compactionIdx = path.findIndex((e) => e.type === \"compaction\" && e.id === compaction.id);\n\n\t\t// Emit kept messages (before compaction, starting from firstKeptEntryId)\n\t\tlet foundFirstKept = false;\n\t\tfor (let i = 0; i < compactionIdx; i++) {\n\t\t\tconst entry = path[i];\n\t\t\tif (entry.id === compaction.firstKeptEntryId) {\n\t\t\t\tfoundFirstKept = true;\n\t\t\t}\n\t\t\tif (foundFirstKept) {\n\t\t\t\tappendMessage(entry);\n\t\t\t}\n\t\t}\n\n\t\t// Emit messages after compaction\n\t\tfor (let i = compactionIdx + 1; i < path.length; i++) {\n\t\t\tconst entry = path[i];\n\t\t\tappendMessage(entry);\n\t\t}\n\t} else {\n\t\t// No compaction - emit all messages, handle branch summaries and custom messages\n\t\tfor (const entry of path) {\n\t\t\tappendMessage(entry);\n\t\t}\n\t}\n\n\treturn { messages, thinkingLevel, model };\n}\n\n/**\n * Compute the default session directory for a cwd.\n * Encodes cwd into a safe directory name under ~/.pi/agent/sessions/.\n */\nfunction getEncodedSessionDirName(cwd: string): string {\n\treturn `--${encodeURIComponent(resolvePath(cwd))}--`;\n}\n\nfunction getLegacySessionDirPath(cwd: string, agentDir: string): string {\n\tconst resolvedCwd = resolvePath(cwd);\n\tconst resolvedAgentDir = resolvePath(agentDir);\n\tconst safePath = `--${resolvedCwd.replace(/^[/\\\\]/, \"\").replace(/[/\\\\:]/g, \"-\")}--`;\n\treturn join(resolvedAgentDir, \"sessions\", safePath);\n}\n\nfunction getDefaultSessionDirPath(cwd: string, agentDir: string): string {\n\tconst resolvedAgentDir = resolvePath(agentDir);\n\treturn join(resolvedAgentDir, \"sessions\", getEncodedSessionDirName(cwd));\n}\n\nfunction getDefaultSessionDirCandidates(cwd: string, agentDir: string): string[] {\n\tconst current = getDefaultSessionDirPath(cwd, agentDir);\n\tconst legacy = getLegacySessionDirPath(cwd, agentDir);\n\treturn current === legacy ? [current] : [current, legacy];\n}\n\nexport function getDefaultSessionDir(cwd: string, agentDir: string): string {\n\tconst sessionDir = getDefaultSessionDirPath(cwd, agentDir);\n\tif (!existsSync(sessionDir)) {\n\t\tmkdirSync(sessionDir, { recursive: true });\n\t}\n\treturn sessionDir;\n}\n\nconst SESSION_READ_BUFFER_SIZE = 1024 * 1024;\n// One line must fit comfortably under V8's max string length (~512MiB chars).\nconst MAX_SESSION_LINE_CHARS = 256 * 1024 * 1024;\n\nfunction parseSessionEntryLine(line: string): FileEntry | null {\n\tif (!line.trim()) return null;\n\ttry {\n\t\treturn JSON.parse(line) as FileEntry;\n\t} catch {\n\t\t// Skip malformed lines\n\t\treturn null;\n\t}\n}\n\n/** Exported for testing */\nexport function loadEntriesFromFile(filePath: string, options?: { maxLineChars?: number }): FileEntry[] {\n\tconst resolvedFilePath = normalizePath(filePath);\n\tif (!existsSync(resolvedFilePath)) return [];\n\n\t// A single line beyond this budget can never become a usable entry, and\n\t// accumulating it would crash the whole load (RangeError near V8's max string\n\t// length) — bricking session resume. Drop it like any other unparseable line.\n\tconst maxLineChars = options?.maxLineChars ?? MAX_SESSION_LINE_CHARS;\n\n\tconst entries: FileEntry[] = [];\n\tconst fd = openSync(resolvedFilePath, \"r\");\n\ttry {\n\t\tconst decoder = new StringDecoder(\"utf8\");\n\t\tconst lines = new StreamingLineDecoder(maxLineChars, { overflow: \"skip\", lineEndings: \"lf\" });\n\t\tconst buffer = Buffer.allocUnsafe(SESSION_READ_BUFFER_SIZE);\n\n\t\twhile (true) {\n\t\t\tconst bytesRead = readSync(fd, buffer, 0, buffer.length, null);\n\t\t\tif (bytesRead === 0) break;\n\t\t\tfor (const line of lines.push(decoder.write(buffer.subarray(0, bytesRead)))) {\n\t\t\t\tconst entry = parseSessionEntryLine(line);\n\t\t\t\tif (entry) entries.push(entry);\n\t\t\t}\n\t\t}\n\n\t\tfor (const line of lines.push(decoder.end())) {\n\t\t\tconst entry = parseSessionEntryLine(line);\n\t\t\tif (entry) entries.push(entry);\n\t\t}\n\t\tconst finalLine = lines.finish();\n\t\tif (finalLine !== undefined) {\n\t\t\tconst entry = parseSessionEntryLine(finalLine);\n\t\t\tif (entry) entries.push(entry);\n\t\t}\n\t} finally {\n\t\tcloseSync(fd);\n\t}\n\n\t// Validate session header\n\tif (entries.length === 0) return entries;\n\tconst header = entries[0];\n\tif (header.type !== \"session\" || typeof (header as { id?: unknown }).id !== \"string\") {\n\t\treturn [];\n\t}\n\n\treturn entries;\n}\n\ninterface SessionEntryFileLocation {\n\toffset: number;\n\tlength: number;\n}\n\nconst ENTRY_ID_PREFIX_BYTES = 64 * 1024;\nconst COMPACTED_PAYLOAD_RELEASE_MIN_CHARS = 16 * 1024;\n\nfunction indexSessionEntryFileLocations(\n\tfilePath: string,\n\tstartOffset: number,\n\tlocations: Map<string, SessionEntryFileLocation>,\n\tretainedIds: ReadonlySet<string>,\n): number {\n\tconst fd = openSync(filePath, \"r\");\n\tconst buffer = Buffer.allocUnsafe(SESSION_READ_BUFFER_SIZE);\n\tlet position = startOffset;\n\tlet lineOffset = startOffset;\n\tlet lineLength = 0;\n\tlet prefixLength = 0;\n\tlet prefixParts: Buffer[] = [];\n\n\tconst finishLine = (): void => {\n\t\tif (prefixLength > 0) {\n\t\t\tconst prefix =\n\t\t\t\tprefixParts.length === 1\n\t\t\t\t\t? prefixParts[0].toString(\"utf8\")\n\t\t\t\t\t: Buffer.concat(prefixParts, prefixLength).toString(\"utf8\");\n\t\t\tconst id = /\"id\"\\s*:\\s*\"([A-Za-z0-9_-]+)\"/.exec(prefix)?.[1];\n\t\t\tif (id && retainedIds.has(id)) locations.set(id, { offset: lineOffset, length: lineLength });\n\t\t}\n\t\tlineLength = 0;\n\t\tprefixLength = 0;\n\t\tprefixParts = [];\n\t};\n\n\ttry {\n\t\twhile (true) {\n\t\t\tconst bytesRead = readSync(fd, buffer, 0, buffer.length, position);\n\t\t\tif (bytesRead === 0) break;\n\t\t\tconst chunkOffset = position;\n\t\t\tposition += bytesRead;\n\t\t\tlet segmentStart = 0;\n\t\t\twhile (segmentStart < bytesRead) {\n\t\t\t\tconst newlineIndex = buffer.indexOf(0x0a, segmentStart);\n\t\t\t\tconst segmentEnd = newlineIndex === -1 || newlineIndex >= bytesRead ? bytesRead : newlineIndex;\n\t\t\t\tconst segmentLength = segmentEnd - segmentStart;\n\t\t\t\tlineLength += segmentLength;\n\t\t\t\tconst prefixRemaining = ENTRY_ID_PREFIX_BYTES - prefixLength;\n\t\t\t\tif (prefixRemaining > 0 && segmentLength > 0) {\n\t\t\t\t\tconst retainedLength = Math.min(prefixRemaining, segmentLength);\n\t\t\t\t\tprefixParts.push(Buffer.from(buffer.subarray(segmentStart, segmentStart + retainedLength)));\n\t\t\t\t\tprefixLength += retainedLength;\n\t\t\t\t}\n\t\t\t\tif (newlineIndex === -1 || newlineIndex >= bytesRead) break;\n\t\t\t\tfinishLine();\n\t\t\t\tlineOffset = chunkOffset + newlineIndex + 1;\n\t\t\t\tsegmentStart = newlineIndex + 1;\n\t\t\t}\n\t\t}\n\t\tif (lineLength > 0) finishLine();\n\t\treturn position;\n\t} finally {\n\t\tcloseSync(fd);\n\t}\n}\n\nfunction retainedStringChars(value: unknown, stopAfter: number): number {\n\tconst pending: unknown[] = [value];\n\tconst seen = new WeakSet<object>();\n\tlet chars = 0;\n\twhile (pending.length > 0) {\n\t\tconst item = pending.pop();\n\t\tif (typeof item === \"string\") {\n\t\t\tchars += item.length;\n\t\t\tif (chars >= stopAfter) return chars;\n\t\t\tcontinue;\n\t\t}\n\t\tif (!item || typeof item !== \"object\" || seen.has(item)) continue;\n\t\tseen.add(item);\n\t\tif (Array.isArray(item)) {\n\t\t\tpending.push(...item);\n\t\t} else {\n\t\t\tpending.push(...Object.values(item));\n\t\t}\n\t}\n\treturn chars;\n}\n\nfunction readSessionHeader(filePath: string): SessionHeader | null {\n\ttry {\n\t\tconst fd = openSync(filePath, \"r\");\n\t\tconst buffer = Buffer.alloc(512);\n\t\tconst bytesRead = readSync(fd, buffer, 0, 512, 0);\n\t\tcloseSync(fd);\n\t\tconst firstLine = buffer.toString(\"utf8\", 0, bytesRead).split(\"\\n\")[0];\n\t\tif (!firstLine) return null;\n\t\tconst header = JSON.parse(firstLine) as Record<string, unknown>;\n\t\tif (header.type !== \"session\" || typeof header.id !== \"string\") {\n\t\t\treturn null;\n\t\t}\n\t\treturn header as unknown as SessionHeader;\n\t} catch {\n\t\treturn null;\n\t}\n}\n\nfunction getSessionHeaderCwd(header: SessionHeader): string | undefined {\n\tconst cwd = (header as { cwd?: unknown }).cwd;\n\treturn typeof cwd === \"string\" ? cwd : undefined;\n}\n\nfunction sessionCwdMatches(cwd: string | undefined, resolvedCwd: string): boolean {\n\treturn cwd !== undefined && cwd !== \"\" && resolvePath(cwd) === resolvedCwd;\n}\n\n/** Exported for testing */\nexport function findMostRecentSession(sessionDir: string, cwd?: string): string | null {\n\tconst resolvedSessionDir = normalizePath(sessionDir);\n\tconst resolvedCwd = cwd ? resolvePath(cwd) : undefined;\n\ttry {\n\t\tconst files = readdirSync(resolvedSessionDir)\n\t\t\t.filter((f) => f.endsWith(\".jsonl\"))\n\t\t\t.map((f) => join(resolvedSessionDir, f))\n\t\t\t.map((path) => ({ path, header: readSessionHeader(path) }))\n\t\t\t.filter(\n\t\t\t\t(file): file is { path: string; header: SessionHeader } =>\n\t\t\t\t\tfile.header !== null &&\n\t\t\t\t\t!isAutoLearnSessionId(file.header.id) &&\n\t\t\t\t\t(!resolvedCwd || sessionCwdMatches(getSessionHeaderCwd(file.header), resolvedCwd)),\n\t\t\t)\n\t\t\t.flatMap(({ path }) => {\n\t\t\t\ttry {\n\t\t\t\t\treturn [{ path, mtime: statSync(path).mtime }];\n\t\t\t\t} catch {\n\t\t\t\t\treturn [];\n\t\t\t\t}\n\t\t\t})\n\t\t\t.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());\n\n\t\treturn files[0]?.path || null;\n\t} catch {\n\t\treturn null;\n\t}\n}\n\nfunction findMostRecentSessionInDirs(sessionDirs: string[], cwd?: string): string | null {\n\tlet mostRecent: { path: string; mtime: number } | null = null;\n\tfor (const dir of sessionDirs) {\n\t\tconst sessionPath = findMostRecentSession(dir, cwd);\n\t\tif (!sessionPath) continue;\n\t\ttry {\n\t\t\tconst mtime = statSync(sessionPath).mtime.getTime();\n\t\t\tif (!mostRecent || mtime > mostRecent.mtime) {\n\t\t\t\tmostRecent = { path: sessionPath, mtime };\n\t\t\t}\n\t\t} catch {\n\t\t\t// Ignore races/deleted files\n\t\t}\n\t}\n\treturn mostRecent?.path ?? null;\n}\n\nfunction isMessageWithContent(message: AgentMessage): message is Message {\n\treturn typeof (message as Message).role === \"string\" && \"content\" in message;\n}\n\nfunction extractTextContent(message: Message): string {\n\tconst content = message.content;\n\tif (typeof content === \"string\") {\n\t\treturn content;\n\t}\n\treturn content\n\t\t.filter((block): block is TextContent => block.type === \"text\")\n\t\t.map((block) => block.text)\n\t\t.join(\" \");\n}\n\nconst MAX_SESSION_SEARCH_TEXT_CHARS = 20_000;\n\nfunction getMessageActivityTime(entry: SessionMessageEntry): number | undefined {\n\tconst message = entry.message;\n\tif (!isMessageWithContent(message)) return undefined;\n\tif (message.role !== \"user\" && message.role !== \"assistant\") return undefined;\n\n\tconst msgTimestamp = (message as { timestamp?: number }).timestamp;\n\tif (typeof msgTimestamp === \"number\") {\n\t\treturn msgTimestamp;\n\t}\n\n\tconst t = new Date(entry.timestamp).getTime();\n\treturn Number.isNaN(t) ? undefined : t;\n}\n\nasync function buildSessionInfo(filePath: string): Promise<SessionInfo | null> {\n\ttry {\n\t\tconst stats = await stat(filePath);\n\t\tlet header: SessionHeader | null = null;\n\t\tlet messageCount = 0;\n\t\tlet firstMessage = \"\";\n\t\tlet allMessagesText = \"\";\n\t\tlet name: string | undefined;\n\t\tlet lastActivityTime: number | undefined;\n\n\t\tconst appendSearchText = (text: string) => {\n\t\t\tif (!text || allMessagesText.length >= MAX_SESSION_SEARCH_TEXT_CHARS) return;\n\t\t\tconst prefix = allMessagesText.length === 0 ? \"\" : \" \";\n\t\t\tconst remaining = MAX_SESSION_SEARCH_TEXT_CHARS - allMessagesText.length - prefix.length;\n\t\t\tif (remaining <= 0) return;\n\t\t\tallMessagesText += `${prefix}${text.slice(0, remaining)}`;\n\t\t};\n\n\t\tconst rl = createInterface({\n\t\t\tinput: createReadStream(filePath, { encoding: \"utf8\" }),\n\t\t\tcrlfDelay: Infinity,\n\t\t});\n\n\t\tfor await (const line of rl) {\n\t\t\tconst entry = parseSessionEntryLine(line);\n\t\t\tif (!entry) continue;\n\n\t\t\tif (!header) {\n\t\t\t\tif (entry.type !== \"session\") return null;\n\t\t\t\theader = entry;\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\t// Extract session name (use latest, including explicit clears)\n\t\t\tif (entry.type === \"session_info\") {\n\t\t\t\tname = entry.name?.trim() || undefined;\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tif (entry.type !== \"message\") continue;\n\t\t\tmessageCount++;\n\n\t\t\tconst activityTime = getMessageActivityTime(entry);\n\t\t\tif (typeof activityTime === \"number\") {\n\t\t\t\tlastActivityTime = Math.max(lastActivityTime ?? 0, activityTime);\n\t\t\t}\n\n\t\t\tconst message = entry.message;\n\t\t\tif (!isMessageWithContent(message)) continue;\n\t\t\tif (message.role !== \"user\" && message.role !== \"assistant\") continue;\n\n\t\t\tconst textContent = extractTextContent(message);\n\t\t\tif (!textContent) continue;\n\n\t\t\tappendSearchText(textContent);\n\t\t\tif (!firstMessage && message.role === \"user\") {\n\t\t\t\tfirstMessage = textContent;\n\t\t\t}\n\t\t}\n\n\t\tif (!header || isAutoLearnSessionId(header.id)) return null;\n\t\tconst cwd = typeof header.cwd === \"string\" ? header.cwd : \"\";\n\t\tconst parentSessionPath = header.parentSession;\n\t\tconst headerTime = typeof header.timestamp === \"string\" ? new Date(header.timestamp).getTime() : NaN;\n\t\tconst modified =\n\t\t\ttypeof lastActivityTime === \"number\" && lastActivityTime > 0\n\t\t\t\t? new Date(lastActivityTime)\n\t\t\t\t: !Number.isNaN(headerTime)\n\t\t\t\t\t? new Date(headerTime)\n\t\t\t\t\t: stats.mtime;\n\n\t\treturn {\n\t\t\tpath: filePath,\n\t\t\tid: header.id,\n\t\t\tcwd,\n\t\t\tname,\n\t\t\tparentSessionPath,\n\t\t\tcreated: new Date(header.timestamp),\n\t\t\tmodified,\n\t\t\tmessageCount,\n\t\t\tfirstMessage: firstMessage || \"(no messages)\",\n\t\t\tallMessagesText,\n\t\t};\n\t} catch {\n\t\treturn null;\n\t}\n}\n\nexport type SessionListProgress = (loaded: number, total: number) => void;\n\nconst MAX_CONCURRENT_SESSION_INFO_LOADS = 10;\n\nasync function buildSessionInfosWithConcurrency(\n\tfiles: string[],\n\tonLoaded: () => void,\n): Promise<(SessionInfo | null)[]> {\n\tconst results: (SessionInfo | null)[] = new Array(files.length).fill(null);\n\tconst inFlight = new Set<Promise<void>>();\n\tlet nextIndex = 0;\n\n\tconst startNext = (): void => {\n\t\tconst index = nextIndex++;\n\t\tconst file = files[index];\n\t\tif (!file) return;\n\n\t\tlet task: Promise<void>;\n\t\ttask = buildSessionInfo(file)\n\t\t\t.then((info) => {\n\t\t\t\tresults[index] = info;\n\t\t\t})\n\t\t\t.catch(() => {\n\t\t\t\tresults[index] = null;\n\t\t\t})\n\t\t\t.finally(() => {\n\t\t\t\tinFlight.delete(task);\n\t\t\t\tonLoaded();\n\t\t\t});\n\t\tinFlight.add(task);\n\t};\n\n\twhile (nextIndex < files.length || inFlight.size > 0) {\n\t\twhile (nextIndex < files.length && inFlight.size < MAX_CONCURRENT_SESSION_INFO_LOADS) {\n\t\t\tstartNext();\n\t\t}\n\t\tif (inFlight.size > 0) {\n\t\t\tawait Promise.race(inFlight);\n\t\t}\n\t}\n\n\treturn results;\n}\n\nasync function countSessionFiles(dir: string): Promise<number> {\n\tif (!existsSync(dir)) return 0;\n\ttry {\n\t\tconst dirEntries = await readdir(dir);\n\t\treturn dirEntries.filter((f) => f.endsWith(\".jsonl\")).length;\n\t} catch {\n\t\treturn 0;\n\t}\n}\n\nasync function listSessionsFromDir(\n\tdir: string,\n\tonProgress?: SessionListProgress,\n\tprogressOffset = 0,\n\tprogressTotal?: number,\n): Promise<SessionInfo[]> {\n\tconst sessions: SessionInfo[] = [];\n\tif (!existsSync(dir)) {\n\t\treturn sessions;\n\t}\n\n\ttry {\n\t\tconst dirEntries = await readdir(dir);\n\t\tconst files = dirEntries.filter((f) => f.endsWith(\".jsonl\")).map((f) => join(dir, f));\n\t\tconst total = progressTotal ?? files.length;\n\n\t\tlet loaded = 0;\n\t\tconst results = await buildSessionInfosWithConcurrency(files, () => {\n\t\t\tloaded++;\n\t\t\tonProgress?.(progressOffset + loaded, total);\n\t\t});\n\t\tfor (const info of results) {\n\t\t\tif (info) {\n\t\t\t\tsessions.push(info);\n\t\t\t}\n\t\t}\n\t} catch {\n\t\t// Return empty list on error\n\t}\n\n\treturn sessions;\n}\n\n/**\n * Manages conversation sessions as append-only trees stored in JSONL files.\n *\n * Each session entry has an id and parentId forming a tree structure. The \"leaf\"\n * pointer tracks the current position. Appending creates a child of the current leaf.\n * Branching moves the leaf to an earlier entry, allowing new branches without\n * modifying history.\n *\n * Use buildSessionContext() to get the resolved message list for the LLM, which\n * handles compaction summaries and follows the path from root to current leaf.\n */\nexport class SessionManager {\n\tprivate sessionId: string = \"\";\n\tprivate sessionFile: string | undefined;\n\tprivate sessionDir: string;\n\tprivate cwd: string;\n\t/**\n\t * Agent config dir (e.g. ~/.pi/agent) used to compute the default session-dir candidates for\n\t * usesDefaultSessionDir(). Injected explicitly by the caller — SessionManager never resolves it\n\t * from app config. Empty string for in-memory sessions (no default dir applies).\n\t */\n\tprivate agentDir: string;\n\tprivate persist: boolean;\n\tprivate flushed: boolean = false;\n\tprivate fileEntries: FileEntry[] = [];\n\tprivate entries: SessionEntry[] = [];\n\tprivate byId: Map<string, SessionEntry> = new Map();\n\tprivate labelsById: Map<string, string> = new Map();\n\tprivate labelTimestampsById: Map<string, string> = new Map();\n\tprivate leafId: string | null = null;\n\tprivate readonly entryFileLocations = new Map<string, SessionEntryFileLocation>();\n\tprivate readonly coldPayloadEntryIds = new Set<string>();\n\tprivate indexedSessionFileBytes = 0;\n\n\tprivate constructor(\n\t\tcwd: string,\n\t\tsessionDir: string,\n\t\tsessionFile: string | undefined,\n\t\tpersist: boolean,\n\t\tagentDir: string,\n\t\tnewSessionOptions?: NewSessionOptions,\n\t) {\n\t\tthis.cwd = resolvePath(cwd);\n\t\tthis.sessionDir = normalizePath(sessionDir);\n\t\tthis.agentDir = agentDir;\n\t\tthis.persist = persist;\n\t\tif (persist && this.sessionDir && !existsSync(this.sessionDir)) {\n\t\t\tmkdirSync(this.sessionDir, { recursive: true });\n\t\t}\n\n\t\tif (sessionFile) {\n\t\t\tthis.setSessionFile(sessionFile);\n\t\t} else {\n\t\t\tthis.newSession(newSessionOptions);\n\t\t}\n\t}\n\n\t/** Switch to a different session file (used for resume and branching) */\n\tsetSessionFile(sessionFile: string): void {\n\t\tthis._resetEntryFileIndex(true);\n\t\tthis.sessionFile = resolvePath(sessionFile);\n\t\tif (existsSync(this.sessionFile)) {\n\t\t\tthis.fileEntries = loadEntriesFromFile(this.sessionFile);\n\n\t\t\t// If file was empty or corrupted (no valid header), truncate and start fresh\n\t\t\t// to avoid appending messages without a session header (which breaks the session)\n\t\t\tif (this.fileEntries.length === 0) {\n\t\t\t\tconst explicitPath = this.sessionFile;\n\t\t\t\tthis.newSession();\n\t\t\t\tthis.sessionFile = explicitPath;\n\t\t\t\tthis._rewriteFile();\n\t\t\t\tthis.flushed = true;\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst header = this.fileEntries.find((e) => e.type === \"session\") as SessionHeader | undefined;\n\t\t\tthis.sessionId = header?.id ?? createSessionId();\n\n\t\t\tif (migrateToCurrentVersion(this.fileEntries)) {\n\t\t\t\tthis._rewriteFile();\n\t\t\t}\n\n\t\t\t// Bound in-memory retention: oversized tool result details from disk would\n\t\t\t// otherwise be pinned in fileEntries for the whole process lifetime.\n\t\t\tfor (const entry of this.fileEntries) {\n\t\t\t\tif (entry.type === \"message\") compactToolResultDetailsForRetention(entry.message);\n\t\t\t}\n\n\t\t\tthis._buildIndex();\n\t\t\tthis.flushed = true;\n\t\t\tthis._releaseExistingCompactedMessagePayloads();\n\t\t} else {\n\t\t\tconst explicitPath = this.sessionFile;\n\t\t\tthis.newSession();\n\t\t\tthis.sessionFile = explicitPath; // preserve explicit path from --session flag\n\t\t}\n\t}\n\n\tnewSession(options?: NewSessionOptions): string | undefined {\n\t\tif (options?.id !== undefined) {\n\t\t\tassertValidSessionId(options.id);\n\t\t}\n\t\tthis.sessionId = options?.id ?? createSessionId();\n\t\tconst timestamp = new Date().toISOString();\n\t\tconst header: SessionHeader = {\n\t\t\ttype: \"session\",\n\t\t\tversion: CURRENT_SESSION_VERSION,\n\t\t\tid: this.sessionId,\n\t\t\ttimestamp,\n\t\t\tcwd: this.cwd,\n\t\t\tparentSession: options?.parentSession,\n\t\t};\n\t\tthis.fileEntries = [header];\n\t\tthis.entries = [];\n\t\tthis.byId.clear();\n\t\tthis.labelsById.clear();\n\t\tthis.labelTimestampsById.clear();\n\t\tthis.leafId = null;\n\t\tthis.flushed = false;\n\t\tthis._resetEntryFileIndex(true);\n\n\t\tif (this.persist) {\n\t\t\tconst fileTimestamp = timestamp.replace(/[:.]/g, \"-\");\n\t\t\tthis.sessionFile = join(this.getSessionDir(), `${fileTimestamp}_${this.sessionId}.jsonl`);\n\t\t}\n\t\treturn this.sessionFile;\n\t}\n\n\tprivate _resetEntryFileIndex(clearColdPayloads = false): void {\n\t\tthis.entryFileLocations.clear();\n\t\tthis.indexedSessionFileBytes = 0;\n\t\tif (clearColdPayloads) this.coldPayloadEntryIds.clear();\n\t}\n\n\tprivate _buildIndex(): void {\n\t\tthis.entries = [];\n\t\tthis.byId.clear();\n\t\tthis.labelsById.clear();\n\t\tthis.labelTimestampsById.clear();\n\t\tthis.leafId = null;\n\t\tfor (const entry of this.fileEntries) {\n\t\t\tif (entry.type === \"session\") continue;\n\t\t\tthis.entries.push(entry);\n\t\t\tthis.byId.set(entry.id, entry);\n\t\t\tthis.leafId = entry.id;\n\t\t\tif (entry.type === \"label\") {\n\t\t\t\tif (entry.label) {\n\t\t\t\t\tthis.labelsById.set(entry.targetId, entry.label);\n\t\t\t\t\tthis.labelTimestampsById.set(entry.targetId, entry.timestamp);\n\t\t\t\t} else {\n\t\t\t\t\tthis.labelsById.delete(entry.targetId);\n\t\t\t\t\tthis.labelTimestampsById.delete(entry.targetId);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tfor (const id of this.coldPayloadEntryIds) {\n\t\t\tif (!this.byId.has(id)) this.coldPayloadEntryIds.delete(id);\n\t\t}\n\t}\n\n\tprivate _rewriteFile(targetFile = this.sessionFile): void {\n\t\tif (!this.persist || !targetFile) return;\n\t\t// Write-then-rename keeps the session file valid at every instant: truncating\n\t\t// in place leaves a torn-file window for crashes or a concurrent process\n\t\t// appending to the same session.\n\t\tconst tempFile = `${targetFile}.${process.pid}.${randomUUID().slice(0, 8)}.tmp`;\n\t\tconst fd = openSync(tempFile, \"wx\");\n\t\ttry {\n\t\t\tfor (const entry of this.fileEntries) {\n\t\t\t\twriteFileSync(fd, `${JSON.stringify(entry)}\\n`);\n\t\t\t}\n\t\t} finally {\n\t\t\tcloseSync(fd);\n\t\t}\n\t\trenameSync(tempFile, targetFile);\n\t\tif (targetFile === this.sessionFile) this._resetEntryFileIndex();\n\t}\n\n\tisPersisted(): boolean {\n\t\treturn this.persist;\n\t}\n\n\tgetCwd(): string {\n\t\treturn this.cwd;\n\t}\n\n\tgetSessionDir(): string {\n\t\treturn this.sessionDir;\n\t}\n\n\tusesDefaultSessionDir(): boolean {\n\t\treturn getDefaultSessionDirCandidates(this.cwd, this.agentDir).includes(this.sessionDir);\n\t}\n\n\tgetSessionId(): string {\n\t\treturn this.sessionId;\n\t}\n\n\tgetSessionFile(): string | undefined {\n\t\treturn this.sessionFile;\n\t}\n\n\tprivate _ensureEntryFileLocations(retainedIds: ReadonlySet<string>): void {\n\t\tif (!this.sessionFile || !existsSync(this.sessionFile)) return;\n\t\tconst fileBytes = statSync(this.sessionFile).size;\n\t\tif (fileBytes < this.indexedSessionFileBytes) this._resetEntryFileIndex();\n\t\tif (fileBytes === this.indexedSessionFileBytes) return;\n\t\tconst idsToIndex = new Set(this.coldPayloadEntryIds);\n\t\tfor (const id of retainedIds) idsToIndex.add(id);\n\t\tthis.indexedSessionFileBytes = indexSessionEntryFileLocations(\n\t\t\tthis.sessionFile,\n\t\t\tthis.indexedSessionFileBytes,\n\t\t\tthis.entryFileLocations,\n\t\t\tidsToIndex,\n\t\t);\n\t}\n\n\tprivate _readCompactedMessageProperty(entryId: string, property: \"content\" | \"output\"): unknown {\n\t\tif (!this.sessionFile) throw new Error(`Compacted session payload ${entryId} is unavailable`);\n\t\tlet location = this.entryFileLocations.get(entryId);\n\t\tif (!location) {\n\t\t\tthis._resetEntryFileIndex();\n\t\t\tthis._ensureEntryFileLocations(new Set([entryId]));\n\t\t\tlocation = this.entryFileLocations.get(entryId);\n\t\t}\n\t\tif (!location) throw new Error(`Compacted session payload ${entryId} is unavailable`);\n\t\tconst fd = openSync(this.sessionFile, \"r\");\n\t\tconst buffer = Buffer.allocUnsafe(location.length);\n\t\tlet bytesRead = 0;\n\t\ttry {\n\t\t\twhile (bytesRead < buffer.length) {\n\t\t\t\tconst count = readSync(fd, buffer, bytesRead, buffer.length - bytesRead, location.offset + bytesRead);\n\t\t\t\tif (count === 0) break;\n\t\t\t\tbytesRead += count;\n\t\t\t}\n\t\t} finally {\n\t\t\tcloseSync(fd);\n\t\t}\n\t\tconst entry = parseSessionEntryLine(buffer.subarray(0, bytesRead).toString(\"utf8\"));\n\t\tif (!entry || entry.type !== \"message\" || entry.id !== entryId) {\n\t\t\tthrow new Error(`Compacted session payload ${entryId} could not be restored`);\n\t\t}\n\t\treturn (entry.message as unknown as Record<string, unknown>)[property];\n\t}\n\n\tprivate _releaseMessageProperty(entry: SessionMessageEntry, property: \"content\" | \"output\"): void {\n\t\tconst message = entry.message as unknown as Record<string, unknown>;\n\t\tconst descriptor = Object.getOwnPropertyDescriptor(message, property);\n\t\tif (!descriptor || descriptor.get || !(\"value\" in descriptor)) return;\n\t\tif (\n\t\t\tretainedStringChars(descriptor.value, COMPACTED_PAYLOAD_RELEASE_MIN_CHARS) <\n\t\t\tCOMPACTED_PAYLOAD_RELEASE_MIN_CHARS\n\t\t) {\n\t\t\treturn;\n\t\t}\n\t\tconst entryId = entry.id;\n\t\tconst enumerable = descriptor.enumerable;\n\t\tthis.coldPayloadEntryIds.add(entryId);\n\t\tObject.defineProperty(message, property, {\n\t\t\tconfigurable: true,\n\t\t\tenumerable,\n\t\t\tget: () => this._readCompactedMessageProperty(entryId, property),\n\t\t\tset: (value: unknown) => {\n\t\t\t\tObject.defineProperty(message, property, {\n\t\t\t\t\tconfigurable: true,\n\t\t\t\t\tenumerable,\n\t\t\t\t\twritable: true,\n\t\t\t\t\tvalue,\n\t\t\t\t});\n\t\t\t},\n\t\t});\n\t}\n\n\tprivate _releaseExistingCompactedMessagePayloads(): void {\n\t\tlet current = this.leafId ? this.byId.get(this.leafId) : undefined;\n\t\twhile (current) {\n\t\t\tif (current.type === \"compaction\") {\n\t\t\t\tthis._releaseCompactedMessagePayloads(current.firstKeptEntryId, current.parentId);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tcurrent = current.parentId ? this.byId.get(current.parentId) : undefined;\n\t\t}\n\t}\n\n\tprivate _releaseCompactedMessagePayloads(firstKeptEntryId: string, compactionParentId: string | null): void {\n\t\tif (!this.persist || !this.flushed || !this.sessionFile) return;\n\t\tconst keptEntryIds = new Set<string>();\n\t\tlet currentId = compactionParentId;\n\t\tlet foundFirstKept = false;\n\t\twhile (currentId) {\n\t\t\tkeptEntryIds.add(currentId);\n\t\t\tif (currentId === firstKeptEntryId) {\n\t\t\t\tfoundFirstKept = true;\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcurrentId = this.byId.get(currentId)?.parentId ?? null;\n\t\t}\n\t\tif (!foundFirstKept) return;\n\t\tconst retainedIds = new Set<string>();\n\t\tconst releases: Array<{ entry: SessionMessageEntry; properties: Array<\"content\" | \"output\"> }> = [];\n\t\tfor (const entry of this.entries) {\n\t\t\tif (entry.type !== \"message\") continue;\n\t\t\tconst message = entry.message as unknown as Record<string, unknown>;\n\t\t\tconst properties: Array<\"content\" | \"output\"> = [];\n\t\t\tfor (const property of [\"content\", \"output\"] as const) {\n\t\t\t\tconst descriptor = Object.getOwnPropertyDescriptor(message, property);\n\t\t\t\tif (\n\t\t\t\t\tdescriptor &&\n\t\t\t\t\t!descriptor.get &&\n\t\t\t\t\t\"value\" in descriptor &&\n\t\t\t\t\tretainedStringChars(descriptor.value, COMPACTED_PAYLOAD_RELEASE_MIN_CHARS) >=\n\t\t\t\t\t\tCOMPACTED_PAYLOAD_RELEASE_MIN_CHARS\n\t\t\t\t) {\n\t\t\t\t\tretainedIds.add(entry.id);\n\t\t\t\t\tif (!keptEntryIds.has(entry.id)) properties.push(property);\n\t\t\t\t}\n\t\t\t}\n\t\t\tif (properties.length > 0) releases.push({ entry, properties });\n\t\t}\n\t\tthis._ensureEntryFileLocations(retainedIds);\n\t\tfor (const release of releases) {\n\t\t\tif (!this.entryFileLocations.has(release.entry.id)) continue;\n\t\t\tfor (const property of release.properties) this._releaseMessageProperty(release.entry, property);\n\t\t}\n\t}\n\n\t_persist(entry: SessionEntry): void {\n\t\tif (!this.persist || !this.sessionFile) return;\n\n\t\tconst hasAssistant = this.fileEntries.some((e) => e.type === \"message\" && e.message.role === \"assistant\");\n\t\tif (!hasAssistant) {\n\t\t\tif (this.flushed) {\n\t\t\t\tappendFileSync(this.sessionFile, `${JSON.stringify(entry)}\\n`);\n\t\t\t} else {\n\t\t\t\t// Mark as not flushed so when assistant arrives, all entries get written\n\t\t\t\tthis.flushed = false;\n\t\t\t}\n\t\t\treturn;\n\t\t}\n\n\t\tif (!this.flushed) {\n\t\t\tconst fd = openSync(this.sessionFile, \"wx\");\n\t\t\ttry {\n\t\t\t\tfor (const e of this.fileEntries) {\n\t\t\t\t\twriteFileSync(fd, `${JSON.stringify(e)}\\n`);\n\t\t\t\t}\n\t\t\t} finally {\n\t\t\t\tcloseSync(fd);\n\t\t\t}\n\t\t\tthis.flushed = true;\n\t\t} else {\n\t\t\tappendFileSync(this.sessionFile, `${JSON.stringify(entry)}\\n`);\n\t\t}\n\t}\n\n\tprivate _appendEntry(entry: SessionEntry): void {\n\t\tthis.fileEntries.push(entry);\n\t\tthis.entries.push(entry);\n\t\tthis.byId.set(entry.id, entry);\n\t\tthis.leafId = entry.id;\n\t\tthis._persist(entry);\n\t}\n\n\t/** Append a message as child of current leaf, then advance leaf. Returns entry id.\n\t * Does not allow writing CompactionSummaryMessage and BranchSummaryMessage directly.\n\t * Reason: we want these to be top-level entries in the session, not message session entries,\n\t * so it is easier to find them.\n\t * These need to be appended via appendCompaction() and appendBranchSummary() methods.\n\t */\n\tappendMessage(message: Message | CustomMessage | BashExecutionMessage): string {\n\t\tconst entry: SessionMessageEntry = {\n\t\t\ttype: \"message\",\n\t\t\tid: generateId(this.byId),\n\t\t\tparentId: this.leafId,\n\t\t\ttimestamp: new Date().toISOString(),\n\t\t\tmessage,\n\t\t};\n\t\tthis._appendEntry(entry);\n\t\treturn entry.id;\n\t}\n\n\t/** Append a thinking level change as child of current leaf, then advance leaf. Returns entry id. */\n\tappendThinkingLevelChange(thinkingLevel: string): string {\n\t\tconst entry: ThinkingLevelChangeEntry = {\n\t\t\ttype: \"thinking_level_change\",\n\t\t\tid: generateId(this.byId),\n\t\t\tparentId: this.leafId,\n\t\t\ttimestamp: new Date().toISOString(),\n\t\t\tthinkingLevel,\n\t\t};\n\t\tthis._appendEntry(entry);\n\t\treturn entry.id;\n\t}\n\n\t/** Append a model change as child of current leaf, then advance leaf. Returns entry id. */\n\tappendModelChange(provider: string, modelId: string): string {\n\t\tconst entry: ModelChangeEntry = {\n\t\t\ttype: \"model_change\",\n\t\t\tid: generateId(this.byId),\n\t\t\tparentId: this.leafId,\n\t\t\ttimestamp: new Date().toISOString(),\n\t\t\tprovider,\n\t\t\tmodelId,\n\t\t};\n\t\tthis._appendEntry(entry);\n\t\treturn entry.id;\n\t}\n\n\t/** Append a compaction summary as child of current leaf, then advance leaf. Returns entry id. */\n\tappendCompaction<T = unknown>(\n\t\tsummary: string,\n\t\tfirstKeptEntryId: string,\n\t\ttokensBefore: number,\n\t\tdetails?: T,\n\t\tfromHook?: boolean,\n\t): string {\n\t\tconst compactionParentId = this.leafId;\n\t\tconst entry: CompactionEntry<T> = {\n\t\t\ttype: \"compaction\",\n\t\t\tid: generateId(this.byId),\n\t\t\tparentId: compactionParentId,\n\t\t\ttimestamp: new Date().toISOString(),\n\t\t\tsummary,\n\t\t\tfirstKeptEntryId,\n\t\t\ttokensBefore,\n\t\t\tdetails,\n\t\t\tfromHook,\n\t\t};\n\t\tthis._appendEntry(entry);\n\t\tthis._releaseCompactedMessagePayloads(firstKeptEntryId, compactionParentId);\n\t\treturn entry.id;\n\t}\n\n\t/** Append a custom entry (for extensions) as child of current leaf, then advance leaf. Returns entry id. */\n\tappendCustomEntry(customType: string, data?: unknown): string {\n\t\tconst entry: CustomEntry = {\n\t\t\ttype: \"custom\",\n\t\t\tcustomType,\n\t\t\tdata,\n\t\t\tid: generateId(this.byId),\n\t\t\tparentId: this.leafId,\n\t\t\ttimestamp: new Date().toISOString(),\n\t\t};\n\t\tthis._appendEntry(entry);\n\t\treturn entry.id;\n\t}\n\n\t/** Append a session info entry (e.g., display name). Returns entry id. */\n\tappendSessionInfo(name: string): string {\n\t\tconst entry: SessionInfoEntry = {\n\t\t\ttype: \"session_info\",\n\t\t\tid: generateId(this.byId),\n\t\t\tparentId: this.leafId,\n\t\t\ttimestamp: new Date().toISOString(),\n\t\t\tname: name.trim(),\n\t\t};\n\t\tthis._appendEntry(entry);\n\t\treturn entry.id;\n\t}\n\n\t/** Get the current session name from the latest session_info entry, if any. */\n\tgetSessionName(): string | undefined {\n\t\t// Walk entries in reverse to find the latest session_info entry.\n\t\t// Empty names explicitly clear the session title.\n\t\tconst entries = this.getEntries();\n\t\tfor (let i = entries.length - 1; i >= 0; i--) {\n\t\t\tconst entry = entries[i];\n\t\t\tif (entry.type === \"session_info\") {\n\t\t\t\treturn entry.name?.trim() || undefined;\n\t\t\t}\n\t\t}\n\t\treturn undefined;\n\t}\n\n\t/**\n\t * Append a custom message entry (for extensions) that participates in LLM context.\n\t * @param customType Extension identifier for filtering on reload\n\t * @param content Message content (string or TextContent/ImageContent array)\n\t * @param display Whether to show in TUI (true = styled display, false = hidden)\n\t * @param details Optional extension-specific metadata (not sent to LLM)\n\t * @returns Entry id\n\t */\n\tappendCustomMessageEntry<T = unknown>(\n\t\tcustomType: string,\n\t\tcontent: string | (TextContent | ImageContent)[],\n\t\tdisplay: boolean,\n\t\tdetails?: T,\n\t): string {\n\t\tconst entry: CustomMessageEntry<T> = {\n\t\t\ttype: \"custom_message\",\n\t\t\tcustomType,\n\t\t\tcontent,\n\t\t\tdisplay,\n\t\t\tdetails,\n\t\t\tid: generateId(this.byId),\n\t\t\tparentId: this.leafId,\n\t\t\ttimestamp: new Date().toISOString(),\n\t\t};\n\t\tthis._appendEntry(entry);\n\t\treturn entry.id;\n\t}\n\n\t// =========================================================================\n\t// Tree Traversal\n\t// =========================================================================\n\n\tgetLeafId(): string | null {\n\t\treturn this.leafId;\n\t}\n\n\tgetLeafEntry(): SessionEntry | undefined {\n\t\treturn this.leafId ? this.byId.get(this.leafId) : undefined;\n\t}\n\n\tgetEntry(id: string): SessionEntry | undefined {\n\t\treturn this.byId.get(id);\n\t}\n\n\t/**\n\t * Get all direct children of an entry.\n\t */\n\tgetChildren(parentId: string): SessionEntry[] {\n\t\tconst children: SessionEntry[] = [];\n\t\tfor (const entry of this.byId.values()) {\n\t\t\tif (entry.parentId === parentId) {\n\t\t\t\tchildren.push(entry);\n\t\t\t}\n\t\t}\n\t\treturn children;\n\t}\n\n\t/**\n\t * Get the label for an entry, if any.\n\t */\n\tgetLabel(id: string): string | undefined {\n\t\treturn this.labelsById.get(id);\n\t}\n\n\t/**\n\t * Set or clear a label on an entry.\n\t * Labels are user-defined markers for bookmarking/navigation.\n\t * Pass undefined or empty string to clear the label.\n\t */\n\tappendLabelChange(targetId: string, label: string | undefined): string {\n\t\tif (!this.byId.has(targetId)) {\n\t\t\tthrow new Error(`Entry ${targetId} not found`);\n\t\t}\n\t\tconst entry: LabelEntry = {\n\t\t\ttype: \"label\",\n\t\t\tid: generateId(this.byId),\n\t\t\tparentId: this.leafId,\n\t\t\ttimestamp: new Date().toISOString(),\n\t\t\ttargetId,\n\t\t\tlabel,\n\t\t};\n\t\tthis._appendEntry(entry);\n\t\tif (label) {\n\t\t\tthis.labelsById.set(targetId, label);\n\t\t\tthis.labelTimestampsById.set(targetId, entry.timestamp);\n\t\t} else {\n\t\t\tthis.labelsById.delete(targetId);\n\t\t\tthis.labelTimestampsById.delete(targetId);\n\t\t}\n\t\treturn entry.id;\n\t}\n\n\t/**\n\t * Walk from entry to root, returning all entries in path order.\n\t * Includes all entry types (messages, compaction, model changes, etc.).\n\t * Use buildSessionContext() to get the resolved messages for the LLM.\n\t */\n\tgetBranch(fromId?: string): SessionEntry[] {\n\t\tconst path: SessionEntry[] = [];\n\t\tconst startId = fromId ?? this.leafId;\n\t\tlet current = startId ? this.byId.get(startId) : undefined;\n\t\twhile (current) {\n\t\t\tpath.push(current);\n\t\t\tcurrent = current.parentId ? this.byId.get(current.parentId) : undefined;\n\t\t}\n\t\tpath.reverse();\n\t\treturn path;\n\t}\n\n\t/**\n\t * Most recent custom entry of `customType` on the active branch, walking leaf→root\n\t * ancestry (same traversal direction as getBranch(), but stops at the first match instead\n\t * of building the full path). `fromId` starts the walk at that entry instead of the current\n\t * leaf — callers use this to resume the search past an entry they've already rejected.\n\t * Payload-agnostic: returns the raw entry, callers own decoding/validating `data`.\n\t */\n\tgetLatestCustomEntryOnBranch(customType: string, fromId?: string): CustomEntry | undefined {\n\t\tconst startId = fromId ?? this.leafId;\n\t\tlet current = startId ? this.byId.get(startId) : undefined;\n\t\twhile (current) {\n\t\t\tif (current.type === \"custom\" && current.customType === customType) return current;\n\t\t\tcurrent = current.parentId ? this.byId.get(current.parentId) : undefined;\n\t\t}\n\t\treturn undefined;\n\t}\n\n\t/**\n\t * Build the session context (what gets sent to the LLM).\n\t * Uses tree traversal from current leaf.\n\t */\n\tbuildSessionContext(): SessionContext {\n\t\treturn buildSessionContext(this.getEntries(), this.leafId, this.byId);\n\t}\n\n\t/**\n\t * Get session header.\n\t */\n\tgetHeader(): SessionHeader | null {\n\t\tconst h = this.fileEntries.find((e) => e.type === \"session\");\n\t\treturn h ? (h as SessionHeader) : null;\n\t}\n\n\t/**\n\t * Get all session entries (excludes header). Returns a shallow copy.\n\t * The session is append-only: use appendXXX() to add entries, branch() to\n\t * change the leaf pointer. Entries cannot be modified or deleted.\n\t */\n\tgetEntries(): SessionEntry[] {\n\t\treturn this.entries.slice();\n\t}\n\n\t/** Entries appended at or after `startIndex`, returned as a defensive shallow slice. */\n\tgetEntriesSince(startIndex: number): SessionEntry[] {\n\t\tconst normalizedStart = Number.isFinite(startIndex) ? Math.max(0, Math.floor(startIndex)) : 0;\n\t\treturn this.entries.slice(normalizedStart);\n\t}\n\n\t/** Return current session entry count without allocating a defensive entries array. */\n\tgetEntryCount(): number {\n\t\treturn this.entries.length;\n\t}\n\n\t/**\n\t * Return recent user-entered prompt text for input recall, oldest entry first.\n\t * This walks only the active branch and does not build/render full session context.\n\t */\n\tgetRecentUserInputHistory(limit = 100): string[] {\n\t\tconst maxEntries = Number.isFinite(limit) ? Math.max(0, Math.floor(limit)) : 100;\n\t\tif (maxEntries === 0) return [];\n\t\tconst history: string[] = [];\n\t\tlet entry = this.leafId ? this.byId.get(this.leafId) : undefined;\n\t\twhile (entry && history.length < maxEntries) {\n\t\t\tif (entry.type === \"message\" && entry.message.role === \"user\") {\n\t\t\t\tconst text = this.getUserInputText(entry.message);\n\t\t\t\tif (text) history.push(text);\n\t\t\t}\n\t\t\tentry = entry.parentId ? this.byId.get(entry.parentId) : undefined;\n\t\t}\n\t\treturn history.reverse();\n\t}\n\n\tprivate getUserInputText(message: AgentMessage): string | undefined {\n\t\tif (message.role !== \"user\") return undefined;\n\t\tconst content = message.content;\n\t\tif (typeof content === \"string\") {\n\t\t\tconst text = content.trim();\n\t\t\treturn text.length > 0 ? text : undefined;\n\t\t}\n\t\tconst text = content\n\t\t\t.filter((part): part is TextContent => part.type === \"text\")\n\t\t\t.map((part) => part.text)\n\t\t\t.join(\"\")\n\t\t\t.trim();\n\t\treturn text.length > 0 ? text : undefined;\n\t}\n\n\t/**\n\t * Get the session as a tree structure. Returns a shallow defensive copy of all entries.\n\t * A well-formed session has exactly one root (first entry with parentId === null).\n\t * Orphaned entries (broken parent chain) are also returned as roots.\n\t */\n\tgetTree(): SessionTreeNode[] {\n\t\tconst entries = this.getEntries();\n\t\tconst nodeMap = new Map<string, SessionTreeNode>();\n\t\tconst roots: SessionTreeNode[] = [];\n\n\t\t// Create nodes with resolved labels\n\t\tfor (const entry of entries) {\n\t\t\tconst label = this.labelsById.get(entry.id);\n\t\t\tconst labelTimestamp = this.labelTimestampsById.get(entry.id);\n\t\t\tnodeMap.set(entry.id, { entry, children: [], label, labelTimestamp });\n\t\t}\n\n\t\t// Build tree\n\t\tfor (const entry of entries) {\n\t\t\tconst node = nodeMap.get(entry.id)!;\n\t\t\tif (entry.parentId === null || entry.parentId === entry.id) {\n\t\t\t\troots.push(node);\n\t\t\t} else {\n\t\t\t\tconst parent = nodeMap.get(entry.parentId);\n\t\t\t\tif (parent) {\n\t\t\t\t\tparent.children.push(node);\n\t\t\t\t} else {\n\t\t\t\t\t// Orphan - treat as root\n\t\t\t\t\troots.push(node);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Sort children by timestamp (oldest first, newest at bottom)\n\t\t// Use iterative approach to avoid stack overflow on deep trees\n\t\tconst stack: SessionTreeNode[] = [...roots];\n\t\twhile (stack.length > 0) {\n\t\t\tconst node = stack.pop()!;\n\t\t\tnode.children.sort((a, b) => new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime());\n\t\t\tstack.push(...node.children);\n\t\t}\n\n\t\treturn roots;\n\t}\n\n\t// =========================================================================\n\t// Branching\n\t// =========================================================================\n\n\t/**\n\t * Start a new branch from an earlier entry.\n\t * Moves the leaf pointer to the specified entry. The next appendXXX() call\n\t * will create a child of that entry, forming a new branch. Existing entries\n\t * are not modified or deleted.\n\t */\n\tbranch(branchFromId: string): void {\n\t\tif (!this.byId.has(branchFromId)) {\n\t\t\tthrow new Error(`Entry ${branchFromId} not found`);\n\t\t}\n\t\tthis.leafId = branchFromId;\n\t}\n\n\t/**\n\t * Reset the leaf pointer to null (before any entries).\n\t * The next appendXXX() call will create a new root entry (parentId = null).\n\t * Use this when navigating to re-edit the first user message.\n\t */\n\tresetLeaf(): void {\n\t\tthis.leafId = null;\n\t}\n\n\t/**\n\t * Start a new branch with a summary of the abandoned path.\n\t * Same as branch(), but also appends a branch_summary entry that captures\n\t * context from the abandoned conversation path.\n\t */\n\tbranchWithSummary(branchFromId: string | null, summary: string, details?: unknown, fromHook?: boolean): string {\n\t\tif (branchFromId !== null && !this.byId.has(branchFromId)) {\n\t\t\tthrow new Error(`Entry ${branchFromId} not found`);\n\t\t}\n\t\tthis.leafId = branchFromId;\n\t\tconst entry: BranchSummaryEntry = {\n\t\t\ttype: \"branch_summary\",\n\t\t\tid: generateId(this.byId),\n\t\t\tparentId: branchFromId,\n\t\t\ttimestamp: new Date().toISOString(),\n\t\t\tfromId: branchFromId ?? \"root\",\n\t\t\tsummary,\n\t\t\tdetails,\n\t\t\tfromHook,\n\t\t};\n\t\tthis._appendEntry(entry);\n\t\treturn entry.id;\n\t}\n\n\t/**\n\t * Create a new session file containing only the path from root to the specified leaf.\n\t * Useful for extracting a single conversation path from a branched session.\n\t * Returns the new session file path, or undefined if not persisting.\n\t */\n\tcreateBranchedSession(leafId: string): string | undefined {\n\t\tconst previousSessionFile = this.sessionFile;\n\t\tconst path = this.getBranch(leafId);\n\t\tif (path.length === 0) {\n\t\t\tthrow new Error(`Entry ${leafId} not found`);\n\t\t}\n\n\t\t// Filter out LabelEntry from path - we'll recreate them from the resolved map\n\t\tconst pathWithoutLabels = path.filter((e) => e.type !== \"label\");\n\n\t\tconst newSessionId = createSessionId();\n\t\tconst timestamp = new Date().toISOString();\n\t\tconst fileTimestamp = timestamp.replace(/[:.]/g, \"-\");\n\t\tconst newSessionFile = join(this.getSessionDir(), `${fileTimestamp}_${newSessionId}.jsonl`);\n\n\t\tconst header: SessionHeader = {\n\t\t\ttype: \"session\",\n\t\t\tversion: CURRENT_SESSION_VERSION,\n\t\t\tid: newSessionId,\n\t\t\ttimestamp,\n\t\t\tcwd: this.cwd,\n\t\t\tparentSession: this.persist ? previousSessionFile : undefined,\n\t\t};\n\n\t\t// Collect labels for entries in the path\n\t\tconst pathEntryIds = new Set(pathWithoutLabels.map((e) => e.id));\n\t\tconst labelsToWrite: Array<{ targetId: string; label: string; timestamp: string }> = [];\n\t\tfor (const [targetId, label] of this.labelsById) {\n\t\t\tif (pathEntryIds.has(targetId)) {\n\t\t\t\tlabelsToWrite.push({ targetId, label, timestamp: this.labelTimestampsById.get(targetId)! });\n\t\t\t}\n\t\t}\n\n\t\tif (this.persist) {\n\t\t\t// Build label entries\n\t\t\tconst lastEntryId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null;\n\t\t\tlet parentId = lastEntryId;\n\t\t\tconst labelEntries: LabelEntry[] = [];\n\t\t\tfor (const { targetId, label, timestamp: labelTimestamp } of labelsToWrite) {\n\t\t\t\tconst labelEntry: LabelEntry = {\n\t\t\t\t\ttype: \"label\",\n\t\t\t\t\tid: generateId(new Set(pathEntryIds)),\n\t\t\t\t\tparentId,\n\t\t\t\t\ttimestamp: labelTimestamp,\n\t\t\t\t\ttargetId,\n\t\t\t\t\tlabel,\n\t\t\t\t};\n\t\t\t\tpathEntryIds.add(labelEntry.id);\n\t\t\t\tlabelEntries.push(labelEntry);\n\t\t\t\tparentId = labelEntry.id;\n\t\t\t}\n\n\t\t\tthis.fileEntries = [header, ...pathWithoutLabels, ...labelEntries];\n\t\t\tthis.sessionId = newSessionId;\n\t\t\tthis._buildIndex();\n\n\t\t\t// Only write the file now if it contains an assistant message.\n\t\t\t// Otherwise defer to _persist(), which creates the file on the\n\t\t\t// first assistant response, matching the newSession() contract\n\t\t\t// and avoiding the duplicate-header bug when _persist()'s\n\t\t\t// no-assistant guard later resets flushed to false.\n\t\t\tconst hasAssistant = this.fileEntries.some((e) => e.type === \"message\" && e.message.role === \"assistant\");\n\t\t\tif (hasAssistant) {\n\t\t\t\t// Keep the source file active while cold compacted getters are serialized into the branch.\n\t\t\t\tthis._rewriteFile(newSessionFile);\n\t\t\t\tthis.flushed = true;\n\t\t\t} else {\n\t\t\t\tthis.flushed = false;\n\t\t\t}\n\t\t\tthis.sessionFile = newSessionFile;\n\t\t\tthis._resetEntryFileIndex();\n\n\t\t\treturn newSessionFile;\n\t\t}\n\n\t\t// In-memory mode: replace current session with the path + labels\n\t\tconst labelEntries: LabelEntry[] = [];\n\t\tlet parentId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null;\n\t\tfor (const { targetId, label, timestamp: labelTimestamp } of labelsToWrite) {\n\t\t\tconst labelEntry: LabelEntry = {\n\t\t\t\ttype: \"label\",\n\t\t\t\tid: generateId(new Set([...pathEntryIds, ...labelEntries.map((e) => e.id)])),\n\t\t\t\tparentId,\n\t\t\t\ttimestamp: labelTimestamp,\n\t\t\t\ttargetId,\n\t\t\t\tlabel,\n\t\t\t};\n\t\t\tlabelEntries.push(labelEntry);\n\t\t\tparentId = labelEntry.id;\n\t\t}\n\t\tthis.fileEntries = [header, ...pathWithoutLabels, ...labelEntries];\n\t\tthis.sessionId = newSessionId;\n\t\tthis._buildIndex();\n\t\treturn undefined;\n\t}\n\n\t/**\n\t * Create a new session.\n\t * @param cwd Working directory (stored in session header)\n\t * @param agentDir Agent config dir used to compute the default session dir and default-dir candidates.\n\t * @param sessionDir Optional session directory. If omitted, uses default (<agentDir>/sessions/<encoded-cwd>/).\n\t */\n\tstatic create(cwd: string, agentDir: string, sessionDir?: string, options?: NewSessionOptions): SessionManager {\n\t\tconst dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd, agentDir);\n\t\treturn new SessionManager(cwd, dir, undefined, true, agentDir, options);\n\t}\n\n\t/**\n\t * Open a specific session file.\n\t * @param path Path to session file\n\t * @param agentDir Agent config dir used to compute default-dir candidates for usesDefaultSessionDir().\n\t * @param sessionDir Optional session directory for /new or /branch. If omitted, derives from file's parent.\n\t * @param cwdOverride Optional cwd override instead of the session header cwd.\n\t */\n\tstatic open(path: string, agentDir: string, sessionDir?: string, cwdOverride?: string): SessionManager {\n\t\tconst resolvedPath = resolvePath(path);\n\t\t// Read only the header here; the constructor performs the one full session load.\n\t\tconst header = readSessionHeader(resolvedPath);\n\t\tconst cwd = cwdOverride ?? header?.cwd ?? process.cwd();\n\t\t// If no sessionDir provided, derive from file's parent directory\n\t\tconst dir = sessionDir ? normalizePath(sessionDir) : resolve(resolvedPath, \"..\");\n\t\treturn new SessionManager(cwd, dir, resolvedPath, true, agentDir);\n\t}\n\n\t/**\n\t * Continue the most recent session, or create new if none.\n\t * @param cwd Working directory\n\t * @param agentDir Agent config dir used to compute the default session dir and default-dir candidates.\n\t * @param sessionDir Optional session directory. If omitted, uses default (<agentDir>/sessions/<encoded-cwd>/).\n\t */\n\tstatic continueRecent(cwd: string, agentDir: string, sessionDir?: string): SessionManager {\n\t\tconst dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd, agentDir);\n\t\tconst defaultDirs = getDefaultSessionDirCandidates(cwd, agentDir);\n\t\tconst filterCwd = sessionDir !== undefined && !defaultDirs.includes(dir);\n\t\tconst searchDirs = sessionDir ? [dir] : defaultDirs;\n\t\tconst mostRecent = findMostRecentSessionInDirs(searchDirs, filterCwd ? cwd : undefined);\n\t\tif (mostRecent) {\n\t\t\treturn new SessionManager(cwd, resolve(mostRecent, \"..\"), mostRecent, true, agentDir);\n\t\t}\n\t\treturn new SessionManager(cwd, dir, undefined, true, agentDir);\n\t}\n\n\t/** Create an in-memory session (no file persistence). No agent dir applies (usesDefaultSessionDir is false). */\n\tstatic inMemory(cwd: string = process.cwd()): SessionManager {\n\t\treturn new SessionManager(cwd, \"\", undefined, false, \"\");\n\t}\n\n\t/**\n\t * Fork a session from another project directory into the current project.\n\t * Creates a new session in the target cwd with the full history from the source session.\n\t * @param sourcePath Path to the source session file\n\t * @param targetCwd Target working directory (where the new session will be stored)\n\t * @param agentDir Agent config dir used to compute the default session dir for targetCwd.\n\t * @param sessionDir Optional session directory. If omitted, uses default for targetCwd.\n\t */\n\tstatic forkFrom(\n\t\tsourcePath: string,\n\t\ttargetCwd: string,\n\t\tagentDir: string,\n\t\tsessionDir?: string,\n\t\toptions?: NewSessionOptions,\n\t): SessionManager {\n\t\tconst resolvedSourcePath = resolvePath(sourcePath);\n\t\tconst resolvedTargetCwd = resolvePath(targetCwd);\n\t\tconst sourceEntries = loadEntriesFromFile(resolvedSourcePath);\n\t\tif (sourceEntries.length === 0) {\n\t\t\tthrow new Error(`Cannot fork: source session file is empty or invalid: ${resolvedSourcePath}`);\n\t\t}\n\n\t\tconst sourceHeader = sourceEntries.find((e) => e.type === \"session\") as SessionHeader | undefined;\n\t\tif (!sourceHeader) {\n\t\t\tthrow new Error(`Cannot fork: source session has no header: ${resolvedSourcePath}`);\n\t\t}\n\n\t\tconst dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(resolvedTargetCwd, agentDir);\n\t\tif (!existsSync(dir)) {\n\t\t\tmkdirSync(dir, { recursive: true });\n\t\t}\n\n\t\t// Create new session file with new ID but forked content\n\t\tif (options?.id !== undefined) {\n\t\t\tassertValidSessionId(options.id);\n\t\t}\n\t\tconst newSessionId = options?.id ?? createSessionId();\n\t\tconst timestamp = new Date().toISOString();\n\t\tconst fileTimestamp = timestamp.replace(/[:.]/g, \"-\");\n\t\tconst newSessionFile = join(dir, `${fileTimestamp}_${newSessionId}.jsonl`);\n\n\t\t// Write new header pointing to source as parent, with updated cwd\n\t\tconst newHeader: SessionHeader = {\n\t\t\ttype: \"session\",\n\t\t\tversion: CURRENT_SESSION_VERSION,\n\t\t\tid: newSessionId,\n\t\t\ttimestamp,\n\t\t\tcwd: resolvedTargetCwd,\n\t\t\tparentSession: resolvedSourcePath,\n\t\t};\n\t\twriteFileSync(newSessionFile, `${JSON.stringify(newHeader)}\\n`, { flag: \"wx\" });\n\n\t\t// Copy all non-header entries from source\n\t\tfor (const entry of sourceEntries) {\n\t\t\tif (entry.type !== \"session\") {\n\t\t\t\tappendFileSync(newSessionFile, `${JSON.stringify(entry)}\\n`);\n\t\t\t}\n\t\t}\n\n\t\treturn new SessionManager(resolvedTargetCwd, dir, newSessionFile, true, agentDir);\n\t}\n\n\t/**\n\t * List all sessions for a directory.\n\t * @param cwd Working directory (used to compute default session directory)\n\t * @param agentDir Agent config dir used to compute the default session dir and default-dir candidates.\n\t * @param sessionDir Optional session directory. If omitted, uses default (<agentDir>/sessions/<encoded-cwd>/).\n\t * @param onProgress Optional callback for progress updates (loaded, total)\n\t */\n\tstatic async list(\n\t\tcwd: string,\n\t\tagentDir: string,\n\t\tsessionDir?: string,\n\t\tonProgress?: SessionListProgress,\n\t): Promise<SessionInfo[]> {\n\t\tconst dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd, agentDir);\n\t\tconst defaultDirs = getDefaultSessionDirCandidates(cwd, agentDir);\n\t\tconst filterCwd = sessionDir !== undefined && !defaultDirs.includes(dir);\n\t\tconst resolvedCwd = resolvePath(cwd);\n\t\tconst dirs = sessionDir ? [dir] : defaultDirs;\n\t\tconst progressCounts = await Promise.all(dirs.map((candidateDir) => countSessionFiles(candidateDir)));\n\t\tconst progressTotal = progressCounts.reduce((sum, count) => sum + count, 0);\n\t\tconst allSessions: SessionInfo[] = [];\n\t\tlet progressOffset = 0;\n\t\tfor (let i = 0; i < dirs.length; i++) {\n\t\t\tconst candidateDir = dirs[i];\n\t\t\tconst sessions = (await listSessionsFromDir(candidateDir, onProgress, progressOffset, progressTotal)).filter(\n\t\t\t\t(session) => !filterCwd || sessionCwdMatches(session.cwd, resolvedCwd),\n\t\t\t);\n\t\t\tprogressOffset += progressCounts[i];\n\t\t\tallSessions.push(...sessions);\n\t\t}\n\t\tallSessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());\n\t\treturn allSessions;\n\t}\n\n\t/**\n\t * List all sessions across all project directories under the sessions root.\n\t * @param sessionsDir Sessions root dir to scan for per-project subdirectories (e.g. <agentDir>/sessions).\n\t * @param customSessionDir Optional single directory to list flat instead of scanning the root.\n\t * @param onProgress Optional callback for progress updates (loaded, total)\n\t */\n\tstatic async listAll(sessionsDir: string, onProgress?: SessionListProgress): Promise<SessionInfo[]>;\n\tstatic async listAll(\n\t\tsessionsDir: string,\n\t\tcustomSessionDir?: string,\n\t\tonProgress?: SessionListProgress,\n\t): Promise<SessionInfo[]>;\n\tstatic async listAll(\n\t\tsessionsDir: string,\n\t\tcustomSessionDirOrOnProgress?: string | SessionListProgress,\n\t\tonProgress?: SessionListProgress,\n\t): Promise<SessionInfo[]> {\n\t\tconst customSessionDir =\n\t\t\ttypeof customSessionDirOrOnProgress === \"string\" ? normalizePath(customSessionDirOrOnProgress) : undefined;\n\t\tconst progress = typeof customSessionDirOrOnProgress === \"function\" ? customSessionDirOrOnProgress : onProgress;\n\t\tif (customSessionDir) {\n\t\t\tconst sessions = await listSessionsFromDir(customSessionDir, progress);\n\t\t\tsessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());\n\t\t\treturn sessions;\n\t\t}\n\n\t\tconst resolvedSessionsDir = normalizePath(sessionsDir);\n\n\t\ttry {\n\t\t\tif (!existsSync(resolvedSessionsDir)) {\n\t\t\t\treturn [];\n\t\t\t}\n\t\t\tconst entries = await readdir(resolvedSessionsDir, { withFileTypes: true });\n\t\t\tconst dirs = entries.filter((e) => e.isDirectory()).map((e) => join(resolvedSessionsDir, e.name));\n\n\t\t\t// Count total files first for accurate progress\n\t\t\tlet totalFiles = 0;\n\t\t\tconst dirFiles: string[][] = [];\n\t\t\tfor (const dir of dirs) {\n\t\t\t\ttry {\n\t\t\t\t\tconst files = (await readdir(dir)).filter((f) => f.endsWith(\".jsonl\"));\n\t\t\t\t\tdirFiles.push(files.map((f) => join(dir, f)));\n\t\t\t\t\ttotalFiles += files.length;\n\t\t\t\t} catch {\n\t\t\t\t\tdirFiles.push([]);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Process all files with progress tracking\n\t\t\tlet loaded = 0;\n\t\t\tconst sessions: SessionInfo[] = [];\n\t\t\tconst allFiles = dirFiles.flat();\n\n\t\t\tconst results = await buildSessionInfosWithConcurrency(allFiles, () => {\n\t\t\t\tloaded++;\n\t\t\t\tprogress?.(loaded, totalFiles);\n\t\t\t});\n\n\t\t\tfor (const info of results) {\n\t\t\t\tif (info) {\n\t\t\t\t\tsessions.push(info);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tsessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());\n\t\t\treturn sessions;\n\t\t} catch {\n\t\t\treturn [];\n\t\t}\n\t}\n}\n"]}
1
+ {"version":3,"file":"session-manager.d.ts","sourceRoot":"","sources":["../../src/session/session-manager.ts"],"names":[],"mappings":"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","sourcesContent":["import { randomUUID } from \"node:crypto\";\nimport {\n\tappendFileSync,\n\tcloseSync,\n\tcreateReadStream,\n\texistsSync,\n\tmkdirSync,\n\topenSync,\n\treaddirSync,\n\treadSync,\n\trenameSync,\n\tstatSync,\n\twriteFileSync,\n} from \"node:fs\";\nimport { readdir, stat } from \"node:fs/promises\";\nimport { dirname, join, resolve } from \"node:path\";\nimport { createInterface } from \"node:readline\";\nimport { StringDecoder } from \"node:string_decoder\";\nimport type { ImageContent, Message, TextContent, Usage } from \"@caupulican/pi-ai\";\nimport {\n\ttype BashExecutionMessage,\n\ttype CustomMessage,\n\tcreateBranchSummaryMessage,\n\tcreateCompactionSummaryMessage,\n\tcreateCustomMessage,\n} from \"../messages.ts\";\nimport type { AgentMessage } from \"../types.ts\";\nimport { normalizePath, resolvePath } from \"../utils/paths.ts\";\nimport { StreamingLineDecoder } from \"../utils/streaming-lines.ts\";\nimport { uuidv7 } from \"../uuid.ts\";\nimport { compactToolResultDetailsForRetention } from \"./message-retention.ts\";\n\nexport const CURRENT_SESSION_VERSION = 3;\n\nexport interface SessionHeader {\n\ttype: \"session\";\n\tversion?: number; // v1 sessions don't have this\n\tid: string;\n\ttimestamp: string;\n\tcwd: string;\n\tparentSession?: string;\n}\n\nexport interface NewSessionOptions {\n\tid?: string;\n\tparentSession?: string;\n}\n\nexport interface SessionEntryBase {\n\ttype: string;\n\tid: string;\n\tparentId: string | null;\n\ttimestamp: string;\n}\n\nexport interface SessionMessageEntry extends SessionEntryBase {\n\ttype: \"message\";\n\tmessage: AgentMessage;\n}\n\nexport interface ThinkingLevelChangeEntry extends SessionEntryBase {\n\ttype: \"thinking_level_change\";\n\tthinkingLevel: string;\n}\n\nexport interface ModelChangeEntry extends SessionEntryBase {\n\ttype: \"model_change\";\n\tprovider: string;\n\tmodelId: string;\n}\n\nexport interface CompactionEntry<T = unknown> extends SessionEntryBase {\n\ttype: \"compaction\";\n\tsummary: string;\n\tfirstKeptEntryId: string;\n\ttokensBefore: number;\n\t/** Extension-specific data (e.g., ArtifactIndex, version markers for structured compaction) */\n\tdetails?: T;\n\t/** Usage from every LLM call that produced or verified this summary. */\n\tusage?: Usage;\n\t/** True if generated by an extension, undefined/false if pi-generated (backward compatible) */\n\tfromHook?: boolean;\n}\n\nexport interface BranchSummaryEntry<T = unknown> extends SessionEntryBase {\n\ttype: \"branch_summary\";\n\tfromId: string;\n\tsummary: string;\n\t/** Extension-specific data (not sent to LLM) */\n\tdetails?: T;\n\t/** Usage from every LLM call that produced this summary. */\n\tusage?: Usage;\n\t/** True if generated by an extension, false if pi-generated */\n\tfromHook?: boolean;\n}\n\n/**\n * Custom entry for extensions to store extension-specific data in the session.\n * Use customType to identify your extension's entries.\n *\n * Purpose: Persist extension state across session reloads. On reload, extensions can\n * scan entries for their customType and reconstruct internal state.\n *\n * Does NOT participate in LLM context (ignored by buildSessionContext).\n * For injecting content into context, see CustomMessageEntry.\n */\nexport interface CustomEntry<T = unknown> extends SessionEntryBase {\n\ttype: \"custom\";\n\tcustomType: string;\n\tdata?: T;\n}\n\n/** Label entry for user-defined bookmarks/markers on entries. */\nexport interface LabelEntry extends SessionEntryBase {\n\ttype: \"label\";\n\ttargetId: string;\n\tlabel: string | undefined;\n}\n\n/** Session metadata entry (e.g., user-defined display name). */\nexport interface SessionInfoEntry extends SessionEntryBase {\n\ttype: \"session_info\";\n\tname?: string;\n}\n\n/**\n * Custom message entry for extensions to inject messages into LLM context.\n * Use customType to identify your extension's entries.\n *\n * Unlike CustomEntry, this DOES participate in LLM context.\n * The content is converted to a user message in buildSessionContext().\n * Use details for extension-specific metadata (not sent to LLM).\n *\n * display controls TUI rendering:\n * - false: hidden entirely\n * - true: rendered with distinct styling (different from user messages)\n */\nexport interface CustomMessageEntry<T = unknown> extends SessionEntryBase {\n\ttype: \"custom_message\";\n\tcustomType: string;\n\tcontent: string | (TextContent | ImageContent)[];\n\tdetails?: T;\n\tdisplay: boolean;\n}\n\n/** Session entry - has id/parentId for tree structure (returned by \"read\" methods in SessionManager) */\nexport type SessionEntry =\n\t| SessionMessageEntry\n\t| ThinkingLevelChangeEntry\n\t| ModelChangeEntry\n\t| CompactionEntry\n\t| BranchSummaryEntry\n\t| CustomEntry\n\t| CustomMessageEntry\n\t| LabelEntry\n\t| SessionInfoEntry;\n\n/** Raw file entry (includes header) */\nexport type FileEntry = SessionHeader | SessionEntry;\n\n/** Tree node for getTree() - defensive copy of session structure */\nexport interface SessionTreeNode {\n\tentry: SessionEntry;\n\tchildren: SessionTreeNode[];\n\t/** Resolved label for this entry, if any */\n\tlabel?: string;\n\t/** Timestamp of the latest label change for this entry, if any */\n\tlabelTimestamp?: string;\n}\n\nexport interface SessionContext {\n\tmessages: AgentMessage[];\n\tthinkingLevel: string;\n\tmodel: { provider: string; modelId: string } | null;\n}\n\nexport interface SessionInfo {\n\tpath: string;\n\tid: string;\n\t/** Working directory where the session was started. Empty string for old sessions. */\n\tcwd: string;\n\t/** User-defined display name from session_info entries. */\n\tname?: string;\n\t/** Path to the parent session (if this session was forked). */\n\tparentSessionPath?: string;\n\tcreated: Date;\n\tmodified: Date;\n\tmessageCount: number;\n\tfirstMessage: string;\n\tallMessagesText: string;\n}\n\nexport type ReadonlySessionManager = Pick<\n\tSessionManager,\n\t| \"getCwd\"\n\t| \"getSessionDir\"\n\t| \"getSessionId\"\n\t| \"getSessionFile\"\n\t| \"getLeafId\"\n\t| \"getLeafEntry\"\n\t| \"getEntry\"\n\t| \"getLabel\"\n\t| \"getBranch\"\n\t| \"getLatestCustomEntryOnBranch\"\n\t| \"getHeader\"\n\t| \"getEntries\"\n\t| \"getTree\"\n\t| \"getSessionName\"\n>;\n\nfunction createSessionId(): string {\n\treturn uuidv7();\n}\n\nexport function assertValidSessionId(id: string): void {\n\tif (!/^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/.test(id)) {\n\t\tthrow new Error(\n\t\t\t\"Session id must be non-empty, contain only alphanumeric characters, '-', '_', and '.', and start and end with an alphanumeric character\",\n\t\t);\n\t}\n}\n\nexport function isAutoLearnSessionId(id: string): boolean {\n\treturn id.startsWith(\"auto-learn-\");\n}\n\n/** Generate a unique short ID (8 hex chars, collision-checked) */\nfunction generateId(byId: { has(id: string): boolean }): string {\n\tfor (let i = 0; i < 100; i++) {\n\t\tconst id = randomUUID().slice(0, 8);\n\t\tif (!byId.has(id)) return id;\n\t}\n\t// Fallback to full UUID if somehow we have collisions\n\treturn randomUUID();\n}\n\n/** Migrate v1 → v2: add id/parentId tree structure. Mutates in place. */\nfunction migrateV1ToV2(entries: FileEntry[]): void {\n\tconst ids = new Set<string>();\n\tlet prevId: string | null = null;\n\n\tfor (const entry of entries) {\n\t\tif (entry.type === \"session\") {\n\t\t\tentry.version = 2;\n\t\t\tcontinue;\n\t\t}\n\n\t\tentry.id = generateId(ids);\n\t\tids.add(entry.id);\n\t\tentry.parentId = prevId;\n\t\tprevId = entry.id;\n\n\t\t// Convert firstKeptEntryIndex to firstKeptEntryId for compaction\n\t\tif (entry.type === \"compaction\") {\n\t\t\tconst comp = entry as CompactionEntry & { firstKeptEntryIndex?: number };\n\t\t\tif (typeof comp.firstKeptEntryIndex === \"number\") {\n\t\t\t\tconst targetEntry = entries[comp.firstKeptEntryIndex];\n\t\t\t\tif (targetEntry && targetEntry.type !== \"session\") {\n\t\t\t\t\tcomp.firstKeptEntryId = targetEntry.id;\n\t\t\t\t}\n\t\t\t\tdelete comp.firstKeptEntryIndex;\n\t\t\t}\n\t\t}\n\t}\n}\n\n/** Migrate v2 → v3: rename hookMessage role to custom. Mutates in place. */\nfunction migrateV2ToV3(entries: FileEntry[]): void {\n\tfor (const entry of entries) {\n\t\tif (entry.type === \"session\") {\n\t\t\tentry.version = 3;\n\t\t\tcontinue;\n\t\t}\n\n\t\t// Update message entries with hookMessage role\n\t\tif (entry.type === \"message\") {\n\t\t\tconst msgEntry = entry as SessionMessageEntry;\n\t\t\tif (msgEntry.message && (msgEntry.message as { role: string }).role === \"hookMessage\") {\n\t\t\t\t(msgEntry.message as { role: string }).role = \"custom\";\n\t\t\t}\n\t\t}\n\t}\n}\n\n/**\n * Run all necessary migrations to bring entries to current version.\n * Mutates entries in place. Returns true if any migration was applied.\n */\nfunction migrateToCurrentVersion(entries: FileEntry[]): boolean {\n\tconst header = entries.find((e) => e.type === \"session\") as SessionHeader | undefined;\n\tconst version = header?.version ?? 1;\n\n\tif (version >= CURRENT_SESSION_VERSION) return false;\n\n\tif (version < 2) migrateV1ToV2(entries);\n\tif (version < 3) migrateV2ToV3(entries);\n\n\treturn true;\n}\n\n/** Exported for testing */\nexport function migrateSessionEntries(entries: FileEntry[]): void {\n\tmigrateToCurrentVersion(entries);\n}\n\n/** Exported for compaction.test.ts */\nexport function parseSessionEntries(content: string): FileEntry[] {\n\tconst entries: FileEntry[] = [];\n\tconst lines = content.trim().split(\"\\n\");\n\n\tfor (const line of lines) {\n\t\tif (!line.trim()) continue;\n\t\ttry {\n\t\t\tconst entry = JSON.parse(line) as FileEntry;\n\t\t\tentries.push(entry);\n\t\t} catch {\n\t\t\t// Skip malformed lines\n\t\t}\n\t}\n\n\treturn entries;\n}\n\nexport function getLatestCompactionEntry(entries: SessionEntry[]): CompactionEntry | null {\n\tfor (let i = entries.length - 1; i >= 0; i--) {\n\t\tif (entries[i].type === \"compaction\") {\n\t\t\treturn entries[i] as CompactionEntry;\n\t\t}\n\t}\n\treturn null;\n}\n\n/**\n * Build the session context from entries using tree traversal.\n * If leafId is provided, walks from that entry to root.\n * Handles compaction and branch summaries along the path.\n */\nexport function buildSessionContext(\n\tentries: SessionEntry[],\n\tleafId?: string | null,\n\tbyId?: Map<string, SessionEntry>,\n): SessionContext {\n\t// Build uuid index if not available\n\tif (!byId) {\n\t\tbyId = new Map<string, SessionEntry>();\n\t\tfor (const entry of entries) {\n\t\t\tbyId.set(entry.id, entry);\n\t\t}\n\t}\n\n\t// Find leaf\n\tlet leaf: SessionEntry | undefined;\n\tif (leafId === null) {\n\t\t// Explicitly null - return no messages (navigated to before first entry)\n\t\treturn { messages: [], thinkingLevel: \"off\", model: null };\n\t}\n\tif (leafId) {\n\t\tleaf = byId.get(leafId);\n\t}\n\tif (!leaf) {\n\t\t// Fallback to last entry (when leafId is undefined)\n\t\tleaf = entries[entries.length - 1];\n\t}\n\n\tif (!leaf) {\n\t\treturn { messages: [], thinkingLevel: \"off\", model: null };\n\t}\n\n\t// Walk from leaf to root, then reverse once. Repeated front insertion makes long branches quadratic.\n\tconst path: SessionEntry[] = [];\n\tlet current: SessionEntry | undefined = leaf;\n\twhile (current) {\n\t\tpath.push(current);\n\t\tcurrent = current.parentId ? byId.get(current.parentId) : undefined;\n\t}\n\tpath.reverse();\n\n\t// Extract settings and find compaction\n\tlet thinkingLevel = \"off\";\n\tlet model: { provider: string; modelId: string } | null = null;\n\tlet compaction: CompactionEntry | null = null;\n\n\tfor (const entry of path) {\n\t\tif (entry.type === \"thinking_level_change\") {\n\t\t\tthinkingLevel = entry.thinkingLevel;\n\t\t} else if (entry.type === \"model_change\") {\n\t\t\tmodel = { provider: entry.provider, modelId: entry.modelId };\n\t\t} else if (entry.type === \"message\" && entry.message.role === \"assistant\") {\n\t\t\tmodel = { provider: entry.message.provider, modelId: entry.message.model };\n\t\t} else if (entry.type === \"compaction\") {\n\t\t\tcompaction = entry;\n\t\t}\n\t}\n\n\t// Build messages and collect corresponding entries\n\t// When there's a compaction, we need to:\n\t// 1. Emit summary first (entry = compaction)\n\t// 2. Emit kept messages (from firstKeptEntryId up to compaction)\n\t// 3. Emit messages after compaction\n\tconst messages: AgentMessage[] = [];\n\n\tconst appendMessage = (entry: SessionEntry) => {\n\t\tif (entry.type === \"message\") {\n\t\t\tmessages.push(entry.message);\n\t\t} else if (entry.type === \"custom_message\") {\n\t\t\tmessages.push(\n\t\t\t\tcreateCustomMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp),\n\t\t\t);\n\t\t} else if (entry.type === \"branch_summary\" && entry.summary) {\n\t\t\tmessages.push(createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp));\n\t\t}\n\t};\n\n\tif (compaction) {\n\t\t// Emit summary first\n\t\tmessages.push(createCompactionSummaryMessage(compaction.summary, compaction.tokensBefore, compaction.timestamp));\n\n\t\t// Find compaction index in path\n\t\tconst compactionIdx = path.findIndex((e) => e.type === \"compaction\" && e.id === compaction.id);\n\n\t\t// Emit kept messages (before compaction, starting from firstKeptEntryId)\n\t\tlet foundFirstKept = false;\n\t\tfor (let i = 0; i < compactionIdx; i++) {\n\t\t\tconst entry = path[i];\n\t\t\tif (entry.id === compaction.firstKeptEntryId) {\n\t\t\t\tfoundFirstKept = true;\n\t\t\t}\n\t\t\tif (foundFirstKept) {\n\t\t\t\tappendMessage(entry);\n\t\t\t}\n\t\t}\n\n\t\t// Emit messages after compaction\n\t\tfor (let i = compactionIdx + 1; i < path.length; i++) {\n\t\t\tconst entry = path[i];\n\t\t\tappendMessage(entry);\n\t\t}\n\t} else {\n\t\t// No compaction - emit all messages, handle branch summaries and custom messages\n\t\tfor (const entry of path) {\n\t\t\tappendMessage(entry);\n\t\t}\n\t}\n\n\treturn { messages, thinkingLevel, model };\n}\n\n/**\n * Compute the default session directory for a cwd without creating it.\n * Encodes cwd into a safe directory name under ~/.pi/agent/sessions/; persistence creates the\n * parent only when the first durable response is written.\n */\nfunction getEncodedSessionDirName(cwd: string): string {\n\treturn `--${encodeURIComponent(resolvePath(cwd))}--`;\n}\n\nfunction getLegacySessionDirPath(cwd: string, agentDir: string): string {\n\tconst resolvedCwd = resolvePath(cwd);\n\tconst resolvedAgentDir = resolvePath(agentDir);\n\tconst safePath = `--${resolvedCwd.replace(/^[/\\\\]/, \"\").replace(/[/\\\\:]/g, \"-\")}--`;\n\treturn join(resolvedAgentDir, \"sessions\", safePath);\n}\n\nfunction getDefaultSessionDirPath(cwd: string, agentDir: string): string {\n\tconst resolvedAgentDir = resolvePath(agentDir);\n\treturn join(resolvedAgentDir, \"sessions\", getEncodedSessionDirName(cwd));\n}\n\nfunction getDefaultSessionDirCandidates(cwd: string, agentDir: string): string[] {\n\tconst current = getDefaultSessionDirPath(cwd, agentDir);\n\tconst legacy = getLegacySessionDirPath(cwd, agentDir);\n\treturn current === legacy ? [current] : [current, legacy];\n}\n\nexport function getDefaultSessionDir(cwd: string, agentDir: string): string {\n\treturn getDefaultSessionDirPath(cwd, agentDir);\n}\n\nconst SESSION_READ_BUFFER_SIZE = 1024 * 1024;\n// One line must fit comfortably under V8's max string length (~512MiB chars).\nconst MAX_SESSION_LINE_CHARS = 256 * 1024 * 1024;\n\nfunction parseSessionEntryLine(line: string): FileEntry | null {\n\tif (!line.trim()) return null;\n\ttry {\n\t\treturn JSON.parse(line) as FileEntry;\n\t} catch {\n\t\t// Skip malformed lines\n\t\treturn null;\n\t}\n}\n\n/** Exported for testing */\nexport function loadEntriesFromFile(filePath: string, options?: { maxLineChars?: number }): FileEntry[] {\n\tconst resolvedFilePath = normalizePath(filePath);\n\tif (!existsSync(resolvedFilePath)) return [];\n\n\t// A single line beyond this budget can never become a usable entry, and\n\t// accumulating it would crash the whole load (RangeError near V8's max string\n\t// length) — bricking session resume. Drop it like any other unparseable line.\n\tconst maxLineChars = options?.maxLineChars ?? MAX_SESSION_LINE_CHARS;\n\n\tconst entries: FileEntry[] = [];\n\tconst fd = openSync(resolvedFilePath, \"r\");\n\ttry {\n\t\tconst decoder = new StringDecoder(\"utf8\");\n\t\tconst lines = new StreamingLineDecoder(maxLineChars, { overflow: \"skip\", lineEndings: \"lf\" });\n\t\tconst buffer = Buffer.allocUnsafe(SESSION_READ_BUFFER_SIZE);\n\n\t\twhile (true) {\n\t\t\tconst bytesRead = readSync(fd, buffer, 0, buffer.length, null);\n\t\t\tif (bytesRead === 0) break;\n\t\t\tfor (const line of lines.push(decoder.write(buffer.subarray(0, bytesRead)))) {\n\t\t\t\tconst entry = parseSessionEntryLine(line);\n\t\t\t\tif (entry) entries.push(entry);\n\t\t\t}\n\t\t}\n\n\t\tfor (const line of lines.push(decoder.end())) {\n\t\t\tconst entry = parseSessionEntryLine(line);\n\t\t\tif (entry) entries.push(entry);\n\t\t}\n\t\tconst finalLine = lines.finish();\n\t\tif (finalLine !== undefined) {\n\t\t\tconst entry = parseSessionEntryLine(finalLine);\n\t\t\tif (entry) entries.push(entry);\n\t\t}\n\t} finally {\n\t\tcloseSync(fd);\n\t}\n\n\t// Validate session header\n\tif (entries.length === 0) return entries;\n\tconst header = entries[0];\n\tif (header.type !== \"session\" || typeof (header as { id?: unknown }).id !== \"string\") {\n\t\treturn [];\n\t}\n\n\treturn entries;\n}\n\ninterface SessionEntryFileLocation {\n\toffset: number;\n\tlength: number;\n}\n\nconst ENTRY_ID_PREFIX_BYTES = 64 * 1024;\nconst COMPACTED_PAYLOAD_RELEASE_MIN_CHARS = 16 * 1024;\n\nfunction indexSessionEntryFileLocations(\n\tfilePath: string,\n\tstartOffset: number,\n\tlocations: Map<string, SessionEntryFileLocation>,\n\tretainedIds: ReadonlySet<string>,\n): number {\n\tconst fd = openSync(filePath, \"r\");\n\tconst buffer = Buffer.allocUnsafe(SESSION_READ_BUFFER_SIZE);\n\tlet position = startOffset;\n\tlet lineOffset = startOffset;\n\tlet lineLength = 0;\n\tlet prefixLength = 0;\n\tlet prefixParts: Buffer[] = [];\n\n\tconst finishLine = (): void => {\n\t\tif (prefixLength > 0) {\n\t\t\tconst prefix =\n\t\t\t\tprefixParts.length === 1\n\t\t\t\t\t? prefixParts[0].toString(\"utf8\")\n\t\t\t\t\t: Buffer.concat(prefixParts, prefixLength).toString(\"utf8\");\n\t\t\tconst id = /\"id\"\\s*:\\s*\"([A-Za-z0-9_-]+)\"/.exec(prefix)?.[1];\n\t\t\tif (id && retainedIds.has(id)) locations.set(id, { offset: lineOffset, length: lineLength });\n\t\t}\n\t\tlineLength = 0;\n\t\tprefixLength = 0;\n\t\tprefixParts = [];\n\t};\n\n\ttry {\n\t\twhile (true) {\n\t\t\tconst bytesRead = readSync(fd, buffer, 0, buffer.length, position);\n\t\t\tif (bytesRead === 0) break;\n\t\t\tconst chunkOffset = position;\n\t\t\tposition += bytesRead;\n\t\t\tlet segmentStart = 0;\n\t\t\twhile (segmentStart < bytesRead) {\n\t\t\t\tconst newlineIndex = buffer.indexOf(0x0a, segmentStart);\n\t\t\t\tconst segmentEnd = newlineIndex === -1 || newlineIndex >= bytesRead ? bytesRead : newlineIndex;\n\t\t\t\tconst segmentLength = segmentEnd - segmentStart;\n\t\t\t\tlineLength += segmentLength;\n\t\t\t\tconst prefixRemaining = ENTRY_ID_PREFIX_BYTES - prefixLength;\n\t\t\t\tif (prefixRemaining > 0 && segmentLength > 0) {\n\t\t\t\t\tconst retainedLength = Math.min(prefixRemaining, segmentLength);\n\t\t\t\t\tprefixParts.push(Buffer.from(buffer.subarray(segmentStart, segmentStart + retainedLength)));\n\t\t\t\t\tprefixLength += retainedLength;\n\t\t\t\t}\n\t\t\t\tif (newlineIndex === -1 || newlineIndex >= bytesRead) break;\n\t\t\t\tfinishLine();\n\t\t\t\tlineOffset = chunkOffset + newlineIndex + 1;\n\t\t\t\tsegmentStart = newlineIndex + 1;\n\t\t\t}\n\t\t}\n\t\tif (lineLength > 0) finishLine();\n\t\treturn position;\n\t} finally {\n\t\tcloseSync(fd);\n\t}\n}\n\nfunction retainedStringChars(value: unknown, stopAfter: number): number {\n\tconst pending: unknown[] = [value];\n\tconst seen = new WeakSet<object>();\n\tlet chars = 0;\n\twhile (pending.length > 0) {\n\t\tconst item = pending.pop();\n\t\tif (typeof item === \"string\") {\n\t\t\tchars += item.length;\n\t\t\tif (chars >= stopAfter) return chars;\n\t\t\tcontinue;\n\t\t}\n\t\tif (!item || typeof item !== \"object\" || seen.has(item)) continue;\n\t\tseen.add(item);\n\t\tif (Array.isArray(item)) {\n\t\t\tpending.push(...item);\n\t\t} else {\n\t\t\tpending.push(...Object.values(item));\n\t\t}\n\t}\n\treturn chars;\n}\n\nfunction readSessionHeader(filePath: string): SessionHeader | null {\n\ttry {\n\t\tconst fd = openSync(filePath, \"r\");\n\t\tconst buffer = Buffer.alloc(512);\n\t\tconst bytesRead = readSync(fd, buffer, 0, 512, 0);\n\t\tcloseSync(fd);\n\t\tconst firstLine = buffer.toString(\"utf8\", 0, bytesRead).split(\"\\n\")[0];\n\t\tif (!firstLine) return null;\n\t\tconst header = JSON.parse(firstLine) as Record<string, unknown>;\n\t\tif (header.type !== \"session\" || typeof header.id !== \"string\") {\n\t\t\treturn null;\n\t\t}\n\t\treturn header as unknown as SessionHeader;\n\t} catch {\n\t\treturn null;\n\t}\n}\n\nfunction getSessionHeaderCwd(header: SessionHeader): string | undefined {\n\tconst cwd = (header as { cwd?: unknown }).cwd;\n\treturn typeof cwd === \"string\" ? cwd : undefined;\n}\n\nfunction sessionCwdMatches(cwd: string | undefined, resolvedCwd: string): boolean {\n\treturn cwd !== undefined && cwd !== \"\" && resolvePath(cwd) === resolvedCwd;\n}\n\n/** Exported for testing */\nexport function findMostRecentSession(sessionDir: string, cwd?: string): string | null {\n\tconst resolvedSessionDir = normalizePath(sessionDir);\n\tconst resolvedCwd = cwd ? resolvePath(cwd) : undefined;\n\ttry {\n\t\tconst files = readdirSync(resolvedSessionDir)\n\t\t\t.filter((f) => f.endsWith(\".jsonl\"))\n\t\t\t.map((f) => join(resolvedSessionDir, f))\n\t\t\t.map((path) => ({ path, header: readSessionHeader(path) }))\n\t\t\t.filter(\n\t\t\t\t(file): file is { path: string; header: SessionHeader } =>\n\t\t\t\t\tfile.header !== null &&\n\t\t\t\t\t!isAutoLearnSessionId(file.header.id) &&\n\t\t\t\t\t(!resolvedCwd || sessionCwdMatches(getSessionHeaderCwd(file.header), resolvedCwd)),\n\t\t\t)\n\t\t\t.flatMap(({ path }) => {\n\t\t\t\ttry {\n\t\t\t\t\treturn [{ path, mtime: statSync(path).mtime }];\n\t\t\t\t} catch {\n\t\t\t\t\treturn [];\n\t\t\t\t}\n\t\t\t})\n\t\t\t.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());\n\n\t\treturn files[0]?.path || null;\n\t} catch {\n\t\treturn null;\n\t}\n}\n\nfunction findMostRecentSessionInDirs(sessionDirs: string[], cwd?: string): string | null {\n\tlet mostRecent: { path: string; mtime: number } | null = null;\n\tfor (const dir of sessionDirs) {\n\t\tconst sessionPath = findMostRecentSession(dir, cwd);\n\t\tif (!sessionPath) continue;\n\t\ttry {\n\t\t\tconst mtime = statSync(sessionPath).mtime.getTime();\n\t\t\tif (!mostRecent || mtime > mostRecent.mtime) {\n\t\t\t\tmostRecent = { path: sessionPath, mtime };\n\t\t\t}\n\t\t} catch {\n\t\t\t// Ignore races/deleted files\n\t\t}\n\t}\n\treturn mostRecent?.path ?? null;\n}\n\nfunction isMessageWithContent(message: AgentMessage): message is Message {\n\treturn typeof (message as Message).role === \"string\" && \"content\" in message;\n}\n\nfunction extractTextContent(message: Message): string {\n\tconst content = message.content;\n\tif (typeof content === \"string\") {\n\t\treturn content;\n\t}\n\treturn content\n\t\t.filter((block): block is TextContent => block.type === \"text\")\n\t\t.map((block) => block.text)\n\t\t.join(\" \");\n}\n\nconst MAX_SESSION_SEARCH_TEXT_CHARS = 20_000;\n\nfunction getMessageActivityTime(entry: SessionMessageEntry): number | undefined {\n\tconst message = entry.message;\n\tif (!isMessageWithContent(message)) return undefined;\n\tif (message.role !== \"user\" && message.role !== \"assistant\") return undefined;\n\n\tconst msgTimestamp = (message as { timestamp?: number }).timestamp;\n\tif (typeof msgTimestamp === \"number\") {\n\t\treturn msgTimestamp;\n\t}\n\n\tconst t = new Date(entry.timestamp).getTime();\n\treturn Number.isNaN(t) ? undefined : t;\n}\n\nasync function buildSessionInfo(filePath: string): Promise<SessionInfo | null> {\n\ttry {\n\t\tconst stats = await stat(filePath);\n\t\tlet header: SessionHeader | null = null;\n\t\tlet messageCount = 0;\n\t\tlet firstMessage = \"\";\n\t\tlet allMessagesText = \"\";\n\t\tlet name: string | undefined;\n\t\tlet lastActivityTime: number | undefined;\n\n\t\tconst appendSearchText = (text: string) => {\n\t\t\tif (!text || allMessagesText.length >= MAX_SESSION_SEARCH_TEXT_CHARS) return;\n\t\t\tconst prefix = allMessagesText.length === 0 ? \"\" : \" \";\n\t\t\tconst remaining = MAX_SESSION_SEARCH_TEXT_CHARS - allMessagesText.length - prefix.length;\n\t\t\tif (remaining <= 0) return;\n\t\t\tallMessagesText += `${prefix}${text.slice(0, remaining)}`;\n\t\t};\n\n\t\tconst rl = createInterface({\n\t\t\tinput: createReadStream(filePath, { encoding: \"utf8\" }),\n\t\t\tcrlfDelay: Infinity,\n\t\t});\n\n\t\tfor await (const line of rl) {\n\t\t\tconst entry = parseSessionEntryLine(line);\n\t\t\tif (!entry) continue;\n\n\t\t\tif (!header) {\n\t\t\t\tif (entry.type !== \"session\") return null;\n\t\t\t\theader = entry;\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\t// Extract session name (use latest, including explicit clears)\n\t\t\tif (entry.type === \"session_info\") {\n\t\t\t\tname = entry.name?.trim() || undefined;\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tif (entry.type !== \"message\") continue;\n\t\t\tmessageCount++;\n\n\t\t\tconst activityTime = getMessageActivityTime(entry);\n\t\t\tif (typeof activityTime === \"number\") {\n\t\t\t\tlastActivityTime = Math.max(lastActivityTime ?? 0, activityTime);\n\t\t\t}\n\n\t\t\tconst message = entry.message;\n\t\t\tif (!isMessageWithContent(message)) continue;\n\t\t\tif (message.role !== \"user\" && message.role !== \"assistant\") continue;\n\n\t\t\tconst textContent = extractTextContent(message);\n\t\t\tif (!textContent) continue;\n\n\t\t\tappendSearchText(textContent);\n\t\t\tif (!firstMessage && message.role === \"user\") {\n\t\t\t\tfirstMessage = textContent;\n\t\t\t}\n\t\t}\n\n\t\tif (!header || isAutoLearnSessionId(header.id)) return null;\n\t\tconst cwd = typeof header.cwd === \"string\" ? header.cwd : \"\";\n\t\tconst parentSessionPath = header.parentSession;\n\t\tconst headerTime = typeof header.timestamp === \"string\" ? new Date(header.timestamp).getTime() : NaN;\n\t\tconst modified =\n\t\t\ttypeof lastActivityTime === \"number\" && lastActivityTime > 0\n\t\t\t\t? new Date(lastActivityTime)\n\t\t\t\t: !Number.isNaN(headerTime)\n\t\t\t\t\t? new Date(headerTime)\n\t\t\t\t\t: stats.mtime;\n\n\t\treturn {\n\t\t\tpath: filePath,\n\t\t\tid: header.id,\n\t\t\tcwd,\n\t\t\tname,\n\t\t\tparentSessionPath,\n\t\t\tcreated: new Date(header.timestamp),\n\t\t\tmodified,\n\t\t\tmessageCount,\n\t\t\tfirstMessage: firstMessage || \"(no messages)\",\n\t\t\tallMessagesText,\n\t\t};\n\t} catch {\n\t\treturn null;\n\t}\n}\n\nexport type SessionListProgress = (loaded: number, total: number) => void;\n\nconst MAX_CONCURRENT_SESSION_INFO_LOADS = 10;\n\nasync function buildSessionInfosWithConcurrency(\n\tfiles: string[],\n\tonLoaded: () => void,\n): Promise<(SessionInfo | null)[]> {\n\tconst results: (SessionInfo | null)[] = new Array(files.length).fill(null);\n\tconst inFlight = new Set<Promise<void>>();\n\tlet nextIndex = 0;\n\n\tconst startNext = (): void => {\n\t\tconst index = nextIndex++;\n\t\tconst file = files[index];\n\t\tif (!file) return;\n\n\t\tlet task: Promise<void>;\n\t\ttask = buildSessionInfo(file)\n\t\t\t.then((info) => {\n\t\t\t\tresults[index] = info;\n\t\t\t})\n\t\t\t.catch(() => {\n\t\t\t\tresults[index] = null;\n\t\t\t})\n\t\t\t.finally(() => {\n\t\t\t\tinFlight.delete(task);\n\t\t\t\tonLoaded();\n\t\t\t});\n\t\tinFlight.add(task);\n\t};\n\n\twhile (nextIndex < files.length || inFlight.size > 0) {\n\t\twhile (nextIndex < files.length && inFlight.size < MAX_CONCURRENT_SESSION_INFO_LOADS) {\n\t\t\tstartNext();\n\t\t}\n\t\tif (inFlight.size > 0) {\n\t\t\tawait Promise.race(inFlight);\n\t\t}\n\t}\n\n\treturn results;\n}\n\nasync function countSessionFiles(dir: string): Promise<number> {\n\tif (!existsSync(dir)) return 0;\n\ttry {\n\t\tconst dirEntries = await readdir(dir);\n\t\treturn dirEntries.filter((f) => f.endsWith(\".jsonl\")).length;\n\t} catch {\n\t\treturn 0;\n\t}\n}\n\nasync function listSessionsFromDir(\n\tdir: string,\n\tonProgress?: SessionListProgress,\n\tprogressOffset = 0,\n\tprogressTotal?: number,\n): Promise<SessionInfo[]> {\n\tconst sessions: SessionInfo[] = [];\n\tif (!existsSync(dir)) {\n\t\treturn sessions;\n\t}\n\n\ttry {\n\t\tconst dirEntries = await readdir(dir);\n\t\tconst files = dirEntries.filter((f) => f.endsWith(\".jsonl\")).map((f) => join(dir, f));\n\t\tconst total = progressTotal ?? files.length;\n\n\t\tlet loaded = 0;\n\t\tconst results = await buildSessionInfosWithConcurrency(files, () => {\n\t\t\tloaded++;\n\t\t\tonProgress?.(progressOffset + loaded, total);\n\t\t});\n\t\tfor (const info of results) {\n\t\t\tif (info) {\n\t\t\t\tsessions.push(info);\n\t\t\t}\n\t\t}\n\t} catch {\n\t\t// Return empty list on error\n\t}\n\n\treturn sessions;\n}\n\n/**\n * Manages conversation sessions as append-only trees stored in JSONL files.\n *\n * Each session entry has an id and parentId forming a tree structure. The \"leaf\"\n * pointer tracks the current position. Appending creates a child of the current leaf.\n * Branching moves the leaf to an earlier entry, allowing new branches without\n * modifying history.\n *\n * Use buildSessionContext() to get the resolved message list for the LLM, which\n * handles compaction summaries and follows the path from root to current leaf.\n */\nexport class SessionManager {\n\tprivate sessionId: string = \"\";\n\tprivate sessionFile: string | undefined;\n\tprivate sessionDir: string;\n\tprivate cwd: string;\n\t/**\n\t * Agent config dir (e.g. ~/.pi/agent) used to compute the default session-dir candidates for\n\t * usesDefaultSessionDir(). Injected explicitly by the caller — SessionManager never resolves it\n\t * from app config. Empty string for in-memory sessions (no default dir applies).\n\t */\n\tprivate agentDir: string;\n\tprivate persist: boolean;\n\tprivate flushed: boolean = false;\n\tprivate fileEntries: FileEntry[] = [];\n\tprivate entries: SessionEntry[] = [];\n\tprivate byId: Map<string, SessionEntry> = new Map();\n\tprivate labelsById: Map<string, string> = new Map();\n\tprivate labelTimestampsById: Map<string, string> = new Map();\n\tprivate leafId: string | null = null;\n\tprivate readonly entryFileLocations = new Map<string, SessionEntryFileLocation>();\n\tprivate readonly coldPayloadEntryIds = new Set<string>();\n\tprivate indexedSessionFileBytes = 0;\n\n\tprivate constructor(\n\t\tcwd: string,\n\t\tsessionDir: string,\n\t\tsessionFile: string | undefined,\n\t\tpersist: boolean,\n\t\tagentDir: string,\n\t\tnewSessionOptions?: NewSessionOptions,\n\t) {\n\t\tthis.cwd = resolvePath(cwd);\n\t\tthis.sessionDir = normalizePath(sessionDir);\n\t\tthis.agentDir = agentDir;\n\t\tthis.persist = persist;\n\n\t\tif (sessionFile) {\n\t\t\tthis.setSessionFile(sessionFile);\n\t\t} else {\n\t\t\tthis.newSession(newSessionOptions);\n\t\t}\n\t}\n\n\t/** Switch to a different session file (used for resume and branching) */\n\tsetSessionFile(sessionFile: string): void {\n\t\tthis._resetEntryFileIndex(true);\n\t\tthis.sessionFile = resolvePath(sessionFile);\n\t\tif (existsSync(this.sessionFile)) {\n\t\t\tthis.fileEntries = loadEntriesFromFile(this.sessionFile);\n\n\t\t\t// If file was empty or corrupted (no valid header), truncate and start fresh\n\t\t\t// to avoid appending messages without a session header (which breaks the session)\n\t\t\tif (this.fileEntries.length === 0) {\n\t\t\t\tconst explicitPath = this.sessionFile;\n\t\t\t\tthis.newSession();\n\t\t\t\tthis.sessionFile = explicitPath;\n\t\t\t\tthis._rewriteFile();\n\t\t\t\tthis.flushed = true;\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst header = this.fileEntries.find((e) => e.type === \"session\") as SessionHeader | undefined;\n\t\t\tthis.sessionId = header?.id ?? createSessionId();\n\n\t\t\tif (migrateToCurrentVersion(this.fileEntries)) {\n\t\t\t\tthis._rewriteFile();\n\t\t\t}\n\n\t\t\t// Bound in-memory retention: oversized tool result details from disk would\n\t\t\t// otherwise be pinned in fileEntries for the whole process lifetime.\n\t\t\tfor (const entry of this.fileEntries) {\n\t\t\t\tif (entry.type === \"message\") compactToolResultDetailsForRetention(entry.message);\n\t\t\t}\n\n\t\t\tthis._buildIndex();\n\t\t\tthis.flushed = true;\n\t\t\tthis._releaseExistingCompactedMessagePayloads();\n\t\t} else {\n\t\t\tconst explicitPath = this.sessionFile;\n\t\t\tthis.newSession();\n\t\t\tthis.sessionFile = explicitPath; // preserve explicit path from --session flag\n\t\t}\n\t}\n\n\tnewSession(options?: NewSessionOptions): string | undefined {\n\t\tif (options?.id !== undefined) {\n\t\t\tassertValidSessionId(options.id);\n\t\t}\n\t\tthis.sessionId = options?.id ?? createSessionId();\n\t\tconst timestamp = new Date().toISOString();\n\t\tconst header: SessionHeader = {\n\t\t\ttype: \"session\",\n\t\t\tversion: CURRENT_SESSION_VERSION,\n\t\t\tid: this.sessionId,\n\t\t\ttimestamp,\n\t\t\tcwd: this.cwd,\n\t\t\tparentSession: options?.parentSession,\n\t\t};\n\t\tthis.fileEntries = [header];\n\t\tthis.entries = [];\n\t\tthis.byId.clear();\n\t\tthis.labelsById.clear();\n\t\tthis.labelTimestampsById.clear();\n\t\tthis.leafId = null;\n\t\tthis.flushed = false;\n\t\tthis._resetEntryFileIndex(true);\n\n\t\tif (this.persist) {\n\t\t\tconst fileTimestamp = timestamp.replace(/[:.]/g, \"-\");\n\t\t\tthis.sessionFile = join(this.getSessionDir(), `${fileTimestamp}_${this.sessionId}.jsonl`);\n\t\t}\n\t\treturn this.sessionFile;\n\t}\n\n\tprivate _resetEntryFileIndex(clearColdPayloads = false): void {\n\t\tthis.entryFileLocations.clear();\n\t\tthis.indexedSessionFileBytes = 0;\n\t\tif (clearColdPayloads) this.coldPayloadEntryIds.clear();\n\t}\n\n\tprivate _buildIndex(): void {\n\t\tthis.entries = [];\n\t\tthis.byId.clear();\n\t\tthis.labelsById.clear();\n\t\tthis.labelTimestampsById.clear();\n\t\tthis.leafId = null;\n\t\tfor (const entry of this.fileEntries) {\n\t\t\tif (entry.type === \"session\") continue;\n\t\t\tthis.entries.push(entry);\n\t\t\tthis.byId.set(entry.id, entry);\n\t\t\tthis.leafId = entry.id;\n\t\t\tif (entry.type === \"label\") {\n\t\t\t\tif (entry.label) {\n\t\t\t\t\tthis.labelsById.set(entry.targetId, entry.label);\n\t\t\t\t\tthis.labelTimestampsById.set(entry.targetId, entry.timestamp);\n\t\t\t\t} else {\n\t\t\t\t\tthis.labelsById.delete(entry.targetId);\n\t\t\t\t\tthis.labelTimestampsById.delete(entry.targetId);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tfor (const id of this.coldPayloadEntryIds) {\n\t\t\tif (!this.byId.has(id)) this.coldPayloadEntryIds.delete(id);\n\t\t}\n\t}\n\n\tprivate _ensureSessionFileParent(targetFile: string): void {\n\t\tconst parentDir = dirname(targetFile);\n\t\tif (!existsSync(parentDir)) mkdirSync(parentDir, { recursive: true });\n\t}\n\n\tprivate _rewriteFile(targetFile = this.sessionFile): void {\n\t\tif (!this.persist || !targetFile) return;\n\t\tthis._ensureSessionFileParent(targetFile);\n\t\t// Write-then-rename keeps the session file valid at every instant: truncating\n\t\t// in place leaves a torn-file window for crashes or a concurrent process\n\t\t// appending to the same session.\n\t\tconst tempFile = `${targetFile}.${process.pid}.${randomUUID().slice(0, 8)}.tmp`;\n\t\tconst fd = openSync(tempFile, \"wx\");\n\t\ttry {\n\t\t\tfor (const entry of this.fileEntries) {\n\t\t\t\twriteFileSync(fd, `${JSON.stringify(entry)}\\n`);\n\t\t\t}\n\t\t} finally {\n\t\t\tcloseSync(fd);\n\t\t}\n\t\trenameSync(tempFile, targetFile);\n\t\tif (targetFile === this.sessionFile) this._resetEntryFileIndex();\n\t}\n\n\tisPersisted(): boolean {\n\t\treturn this.persist;\n\t}\n\n\tgetCwd(): string {\n\t\treturn this.cwd;\n\t}\n\n\tgetSessionDir(): string {\n\t\treturn this.sessionDir;\n\t}\n\n\tusesDefaultSessionDir(): boolean {\n\t\treturn getDefaultSessionDirCandidates(this.cwd, this.agentDir).includes(this.sessionDir);\n\t}\n\n\tgetSessionId(): string {\n\t\treturn this.sessionId;\n\t}\n\n\tgetSessionFile(): string | undefined {\n\t\treturn this.sessionFile;\n\t}\n\n\tprivate _ensureEntryFileLocations(retainedIds: ReadonlySet<string>): void {\n\t\tif (!this.sessionFile || !existsSync(this.sessionFile)) return;\n\t\tconst fileBytes = statSync(this.sessionFile).size;\n\t\tif (fileBytes < this.indexedSessionFileBytes) this._resetEntryFileIndex();\n\t\tif (fileBytes === this.indexedSessionFileBytes) return;\n\t\tconst idsToIndex = new Set(this.coldPayloadEntryIds);\n\t\tfor (const id of retainedIds) idsToIndex.add(id);\n\t\tthis.indexedSessionFileBytes = indexSessionEntryFileLocations(\n\t\t\tthis.sessionFile,\n\t\t\tthis.indexedSessionFileBytes,\n\t\t\tthis.entryFileLocations,\n\t\t\tidsToIndex,\n\t\t);\n\t}\n\n\tprivate _readCompactedMessageProperty(entryId: string, property: \"content\" | \"output\"): unknown {\n\t\tif (!this.sessionFile) throw new Error(`Compacted session payload ${entryId} is unavailable`);\n\t\tlet location = this.entryFileLocations.get(entryId);\n\t\tif (!location) {\n\t\t\tthis._resetEntryFileIndex();\n\t\t\tthis._ensureEntryFileLocations(new Set([entryId]));\n\t\t\tlocation = this.entryFileLocations.get(entryId);\n\t\t}\n\t\tif (!location) throw new Error(`Compacted session payload ${entryId} is unavailable`);\n\t\tconst fd = openSync(this.sessionFile, \"r\");\n\t\tconst buffer = Buffer.allocUnsafe(location.length);\n\t\tlet bytesRead = 0;\n\t\ttry {\n\t\t\twhile (bytesRead < buffer.length) {\n\t\t\t\tconst count = readSync(fd, buffer, bytesRead, buffer.length - bytesRead, location.offset + bytesRead);\n\t\t\t\tif (count === 0) break;\n\t\t\t\tbytesRead += count;\n\t\t\t}\n\t\t} finally {\n\t\t\tcloseSync(fd);\n\t\t}\n\t\tconst entry = parseSessionEntryLine(buffer.subarray(0, bytesRead).toString(\"utf8\"));\n\t\tif (!entry || entry.type !== \"message\" || entry.id !== entryId) {\n\t\t\tthrow new Error(`Compacted session payload ${entryId} could not be restored`);\n\t\t}\n\t\treturn (entry.message as unknown as Record<string, unknown>)[property];\n\t}\n\n\tprivate _releaseMessageProperty(entry: SessionMessageEntry, property: \"content\" | \"output\"): void {\n\t\tconst message = entry.message as unknown as Record<string, unknown>;\n\t\tconst descriptor = Object.getOwnPropertyDescriptor(message, property);\n\t\tif (!descriptor || descriptor.get || !(\"value\" in descriptor)) return;\n\t\tif (\n\t\t\tretainedStringChars(descriptor.value, COMPACTED_PAYLOAD_RELEASE_MIN_CHARS) <\n\t\t\tCOMPACTED_PAYLOAD_RELEASE_MIN_CHARS\n\t\t) {\n\t\t\treturn;\n\t\t}\n\t\tconst entryId = entry.id;\n\t\tconst enumerable = descriptor.enumerable;\n\t\tthis.coldPayloadEntryIds.add(entryId);\n\t\tObject.defineProperty(message, property, {\n\t\t\tconfigurable: true,\n\t\t\tenumerable,\n\t\t\tget: () => this._readCompactedMessageProperty(entryId, property),\n\t\t\tset: (value: unknown) => {\n\t\t\t\tObject.defineProperty(message, property, {\n\t\t\t\t\tconfigurable: true,\n\t\t\t\t\tenumerable,\n\t\t\t\t\twritable: true,\n\t\t\t\t\tvalue,\n\t\t\t\t});\n\t\t\t},\n\t\t});\n\t}\n\n\tprivate _releaseExistingCompactedMessagePayloads(): void {\n\t\tlet current = this.leafId ? this.byId.get(this.leafId) : undefined;\n\t\twhile (current) {\n\t\t\tif (current.type === \"compaction\") {\n\t\t\t\tthis._releaseCompactedMessagePayloads(current.firstKeptEntryId, current.parentId);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tcurrent = current.parentId ? this.byId.get(current.parentId) : undefined;\n\t\t}\n\t}\n\n\tprivate _releaseCompactedMessagePayloads(firstKeptEntryId: string, compactionParentId: string | null): void {\n\t\tif (!this.persist || !this.flushed || !this.sessionFile) return;\n\t\tconst keptEntryIds = new Set<string>();\n\t\tlet currentId = compactionParentId;\n\t\tlet foundFirstKept = false;\n\t\twhile (currentId) {\n\t\t\tkeptEntryIds.add(currentId);\n\t\t\tif (currentId === firstKeptEntryId) {\n\t\t\t\tfoundFirstKept = true;\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcurrentId = this.byId.get(currentId)?.parentId ?? null;\n\t\t}\n\t\tif (!foundFirstKept) return;\n\t\tconst retainedIds = new Set<string>();\n\t\tconst releases: Array<{ entry: SessionMessageEntry; properties: Array<\"content\" | \"output\"> }> = [];\n\t\tfor (const entry of this.entries) {\n\t\t\tif (entry.type !== \"message\") continue;\n\t\t\tconst message = entry.message as unknown as Record<string, unknown>;\n\t\t\tconst properties: Array<\"content\" | \"output\"> = [];\n\t\t\tfor (const property of [\"content\", \"output\"] as const) {\n\t\t\t\tconst descriptor = Object.getOwnPropertyDescriptor(message, property);\n\t\t\t\tif (\n\t\t\t\t\tdescriptor &&\n\t\t\t\t\t!descriptor.get &&\n\t\t\t\t\t\"value\" in descriptor &&\n\t\t\t\t\tretainedStringChars(descriptor.value, COMPACTED_PAYLOAD_RELEASE_MIN_CHARS) >=\n\t\t\t\t\t\tCOMPACTED_PAYLOAD_RELEASE_MIN_CHARS\n\t\t\t\t) {\n\t\t\t\t\tretainedIds.add(entry.id);\n\t\t\t\t\tif (!keptEntryIds.has(entry.id)) properties.push(property);\n\t\t\t\t}\n\t\t\t}\n\t\t\tif (properties.length > 0) releases.push({ entry, properties });\n\t\t}\n\t\tthis._ensureEntryFileLocations(retainedIds);\n\t\tfor (const release of releases) {\n\t\t\tif (!this.entryFileLocations.has(release.entry.id)) continue;\n\t\t\tfor (const property of release.properties) this._releaseMessageProperty(release.entry, property);\n\t\t}\n\t}\n\n\t_persist(entry: SessionEntry): void {\n\t\tif (!this.persist || !this.sessionFile) return;\n\n\t\tconst hasAssistant = this.fileEntries.some((e) => e.type === \"message\" && e.message.role === \"assistant\");\n\t\tif (!hasAssistant) {\n\t\t\tif (this.flushed) {\n\t\t\t\tthis._ensureSessionFileParent(this.sessionFile);\n\t\t\t\tappendFileSync(this.sessionFile, `${JSON.stringify(entry)}\\n`);\n\t\t\t} else {\n\t\t\t\t// Mark as not flushed so when assistant arrives, all entries get written\n\t\t\t\tthis.flushed = false;\n\t\t\t}\n\t\t\treturn;\n\t\t}\n\n\t\tif (!this.flushed) {\n\t\t\tthis._ensureSessionFileParent(this.sessionFile);\n\t\t\tconst fd = openSync(this.sessionFile, \"wx\");\n\t\t\ttry {\n\t\t\t\tfor (const e of this.fileEntries) {\n\t\t\t\t\twriteFileSync(fd, `${JSON.stringify(e)}\\n`);\n\t\t\t\t}\n\t\t\t} finally {\n\t\t\t\tcloseSync(fd);\n\t\t\t}\n\t\t\tthis.flushed = true;\n\t\t} else {\n\t\t\tthis._ensureSessionFileParent(this.sessionFile);\n\t\t\tappendFileSync(this.sessionFile, `${JSON.stringify(entry)}\\n`);\n\t\t}\n\t}\n\n\tprivate _appendEntry(entry: SessionEntry): void {\n\t\tthis.fileEntries.push(entry);\n\t\tthis.entries.push(entry);\n\t\tthis.byId.set(entry.id, entry);\n\t\tthis.leafId = entry.id;\n\t\tthis._persist(entry);\n\t}\n\n\t/** Append a message as child of current leaf, then advance leaf. Returns entry id.\n\t * Does not allow writing CompactionSummaryMessage and BranchSummaryMessage directly.\n\t * Reason: we want these to be top-level entries in the session, not message session entries,\n\t * so it is easier to find them.\n\t * These need to be appended via appendCompaction() and appendBranchSummary() methods.\n\t */\n\tappendMessage(message: Message | CustomMessage | BashExecutionMessage): string {\n\t\tconst entry: SessionMessageEntry = {\n\t\t\ttype: \"message\",\n\t\t\tid: generateId(this.byId),\n\t\t\tparentId: this.leafId,\n\t\t\ttimestamp: new Date().toISOString(),\n\t\t\tmessage,\n\t\t};\n\t\tthis._appendEntry(entry);\n\t\treturn entry.id;\n\t}\n\n\t/** Append a thinking level change as child of current leaf, then advance leaf. Returns entry id. */\n\tappendThinkingLevelChange(thinkingLevel: string): string {\n\t\tconst entry: ThinkingLevelChangeEntry = {\n\t\t\ttype: \"thinking_level_change\",\n\t\t\tid: generateId(this.byId),\n\t\t\tparentId: this.leafId,\n\t\t\ttimestamp: new Date().toISOString(),\n\t\t\tthinkingLevel,\n\t\t};\n\t\tthis._appendEntry(entry);\n\t\treturn entry.id;\n\t}\n\n\t/** Append a model change as child of current leaf, then advance leaf. Returns entry id. */\n\tappendModelChange(provider: string, modelId: string): string {\n\t\tconst entry: ModelChangeEntry = {\n\t\t\ttype: \"model_change\",\n\t\t\tid: generateId(this.byId),\n\t\t\tparentId: this.leafId,\n\t\t\ttimestamp: new Date().toISOString(),\n\t\t\tprovider,\n\t\t\tmodelId,\n\t\t};\n\t\tthis._appendEntry(entry);\n\t\treturn entry.id;\n\t}\n\n\t/** Append a compaction summary as child of current leaf, then advance leaf. Returns entry id. */\n\tappendCompaction<T = unknown>(\n\t\tsummary: string,\n\t\tfirstKeptEntryId: string,\n\t\ttokensBefore: number,\n\t\tdetails?: T,\n\t\tfromHook?: boolean,\n\t\tusage?: Usage,\n\t): string {\n\t\tconst compactionParentId = this.leafId;\n\t\tconst entry: CompactionEntry<T> = {\n\t\t\ttype: \"compaction\",\n\t\t\tid: generateId(this.byId),\n\t\t\tparentId: compactionParentId,\n\t\t\ttimestamp: new Date().toISOString(),\n\t\t\tsummary,\n\t\t\tfirstKeptEntryId,\n\t\t\ttokensBefore,\n\t\t\tdetails,\n\t\t\tusage,\n\t\t\tfromHook,\n\t\t};\n\t\tthis._appendEntry(entry);\n\t\tthis._releaseCompactedMessagePayloads(firstKeptEntryId, compactionParentId);\n\t\treturn entry.id;\n\t}\n\n\t/** Append a custom entry (for extensions) as child of current leaf, then advance leaf. Returns entry id. */\n\tappendCustomEntry(customType: string, data?: unknown): string {\n\t\tconst entry: CustomEntry = {\n\t\t\ttype: \"custom\",\n\t\t\tcustomType,\n\t\t\tdata,\n\t\t\tid: generateId(this.byId),\n\t\t\tparentId: this.leafId,\n\t\t\ttimestamp: new Date().toISOString(),\n\t\t};\n\t\tthis._appendEntry(entry);\n\t\treturn entry.id;\n\t}\n\n\t/** Append a session info entry (e.g., display name). Returns entry id. */\n\tappendSessionInfo(name: string): string {\n\t\tconst entry: SessionInfoEntry = {\n\t\t\ttype: \"session_info\",\n\t\t\tid: generateId(this.byId),\n\t\t\tparentId: this.leafId,\n\t\t\ttimestamp: new Date().toISOString(),\n\t\t\tname: name.trim(),\n\t\t};\n\t\tthis._appendEntry(entry);\n\t\treturn entry.id;\n\t}\n\n\t/** Get the current session name from the latest session_info entry, if any. */\n\tgetSessionName(): string | undefined {\n\t\t// Walk entries in reverse to find the latest session_info entry.\n\t\t// Empty names explicitly clear the session title.\n\t\tconst entries = this.getEntries();\n\t\tfor (let i = entries.length - 1; i >= 0; i--) {\n\t\t\tconst entry = entries[i];\n\t\t\tif (entry.type === \"session_info\") {\n\t\t\t\treturn entry.name?.trim() || undefined;\n\t\t\t}\n\t\t}\n\t\treturn undefined;\n\t}\n\n\t/**\n\t * Append a custom message entry (for extensions) that participates in LLM context.\n\t * @param customType Extension identifier for filtering on reload\n\t * @param content Message content (string or TextContent/ImageContent array)\n\t * @param display Whether to show in TUI (true = styled display, false = hidden)\n\t * @param details Optional extension-specific metadata (not sent to LLM)\n\t * @returns Entry id\n\t */\n\tappendCustomMessageEntry<T = unknown>(\n\t\tcustomType: string,\n\t\tcontent: string | (TextContent | ImageContent)[],\n\t\tdisplay: boolean,\n\t\tdetails?: T,\n\t): string {\n\t\tconst entry: CustomMessageEntry<T> = {\n\t\t\ttype: \"custom_message\",\n\t\t\tcustomType,\n\t\t\tcontent,\n\t\t\tdisplay,\n\t\t\tdetails,\n\t\t\tid: generateId(this.byId),\n\t\t\tparentId: this.leafId,\n\t\t\ttimestamp: new Date().toISOString(),\n\t\t};\n\t\tthis._appendEntry(entry);\n\t\treturn entry.id;\n\t}\n\n\t// =========================================================================\n\t// Tree Traversal\n\t// =========================================================================\n\n\tgetLeafId(): string | null {\n\t\treturn this.leafId;\n\t}\n\n\tgetLeafEntry(): SessionEntry | undefined {\n\t\treturn this.leafId ? this.byId.get(this.leafId) : undefined;\n\t}\n\n\tgetEntry(id: string): SessionEntry | undefined {\n\t\treturn this.byId.get(id);\n\t}\n\n\t/**\n\t * Get all direct children of an entry.\n\t */\n\tgetChildren(parentId: string): SessionEntry[] {\n\t\tconst children: SessionEntry[] = [];\n\t\tfor (const entry of this.byId.values()) {\n\t\t\tif (entry.parentId === parentId) {\n\t\t\t\tchildren.push(entry);\n\t\t\t}\n\t\t}\n\t\treturn children;\n\t}\n\n\t/**\n\t * Get the label for an entry, if any.\n\t */\n\tgetLabel(id: string): string | undefined {\n\t\treturn this.labelsById.get(id);\n\t}\n\n\t/**\n\t * Set or clear a label on an entry.\n\t * Labels are user-defined markers for bookmarking/navigation.\n\t * Pass undefined or empty string to clear the label.\n\t */\n\tappendLabelChange(targetId: string, label: string | undefined): string {\n\t\tif (!this.byId.has(targetId)) {\n\t\t\tthrow new Error(`Entry ${targetId} not found`);\n\t\t}\n\t\tconst entry: LabelEntry = {\n\t\t\ttype: \"label\",\n\t\t\tid: generateId(this.byId),\n\t\t\tparentId: this.leafId,\n\t\t\ttimestamp: new Date().toISOString(),\n\t\t\ttargetId,\n\t\t\tlabel,\n\t\t};\n\t\tthis._appendEntry(entry);\n\t\tif (label) {\n\t\t\tthis.labelsById.set(targetId, label);\n\t\t\tthis.labelTimestampsById.set(targetId, entry.timestamp);\n\t\t} else {\n\t\t\tthis.labelsById.delete(targetId);\n\t\t\tthis.labelTimestampsById.delete(targetId);\n\t\t}\n\t\treturn entry.id;\n\t}\n\n\t/**\n\t * Walk from entry to root, returning all entries in path order.\n\t * Includes all entry types (messages, compaction, model changes, etc.).\n\t * Use buildSessionContext() to get the resolved messages for the LLM.\n\t */\n\tgetBranch(fromId?: string): SessionEntry[] {\n\t\tconst path: SessionEntry[] = [];\n\t\tconst startId = fromId ?? this.leafId;\n\t\tlet current = startId ? this.byId.get(startId) : undefined;\n\t\twhile (current) {\n\t\t\tpath.push(current);\n\t\t\tcurrent = current.parentId ? this.byId.get(current.parentId) : undefined;\n\t\t}\n\t\tpath.reverse();\n\t\treturn path;\n\t}\n\n\t/**\n\t * Most recent custom entry of `customType` on the active branch, walking leaf→root\n\t * ancestry (same traversal direction as getBranch(), but stops at the first match instead\n\t * of building the full path). `fromId` starts the walk at that entry instead of the current\n\t * leaf — callers use this to resume the search past an entry they've already rejected.\n\t * Payload-agnostic: returns the raw entry, callers own decoding/validating `data`.\n\t */\n\tgetLatestCustomEntryOnBranch(customType: string, fromId?: string): CustomEntry | undefined {\n\t\tconst startId = fromId ?? this.leafId;\n\t\tlet current = startId ? this.byId.get(startId) : undefined;\n\t\twhile (current) {\n\t\t\tif (current.type === \"custom\" && current.customType === customType) return current;\n\t\t\tcurrent = current.parentId ? this.byId.get(current.parentId) : undefined;\n\t\t}\n\t\treturn undefined;\n\t}\n\n\t/**\n\t * Build the session context (what gets sent to the LLM).\n\t * Uses tree traversal from current leaf.\n\t */\n\tbuildSessionContext(): SessionContext {\n\t\treturn buildSessionContext(this.getEntries(), this.leafId, this.byId);\n\t}\n\n\t/**\n\t * Get session header.\n\t */\n\tgetHeader(): SessionHeader | null {\n\t\tconst h = this.fileEntries.find((e) => e.type === \"session\");\n\t\treturn h ? (h as SessionHeader) : null;\n\t}\n\n\t/**\n\t * Get all session entries (excludes header). Returns a shallow copy.\n\t * The session is append-only: use appendXXX() to add entries, branch() to\n\t * change the leaf pointer. Entries cannot be modified or deleted.\n\t */\n\tgetEntries(): SessionEntry[] {\n\t\treturn this.entries.slice();\n\t}\n\n\t/** Entries appended at or after `startIndex`, returned as a defensive shallow slice. */\n\tgetEntriesSince(startIndex: number): SessionEntry[] {\n\t\tconst normalizedStart = Number.isFinite(startIndex) ? Math.max(0, Math.floor(startIndex)) : 0;\n\t\treturn this.entries.slice(normalizedStart);\n\t}\n\n\t/** Return current session entry count without allocating a defensive entries array. */\n\tgetEntryCount(): number {\n\t\treturn this.entries.length;\n\t}\n\n\t/**\n\t * Return recent user-entered prompt text for input recall, oldest entry first.\n\t * This walks only the active branch and does not build/render full session context.\n\t */\n\tgetRecentUserInputHistory(limit = 100): string[] {\n\t\tconst maxEntries = Number.isFinite(limit) ? Math.max(0, Math.floor(limit)) : 100;\n\t\tif (maxEntries === 0) return [];\n\t\tconst history: string[] = [];\n\t\tlet entry = this.leafId ? this.byId.get(this.leafId) : undefined;\n\t\twhile (entry && history.length < maxEntries) {\n\t\t\tif (entry.type === \"message\" && entry.message.role === \"user\") {\n\t\t\t\tconst text = this.getUserInputText(entry.message);\n\t\t\t\tif (text) history.push(text);\n\t\t\t}\n\t\t\tentry = entry.parentId ? this.byId.get(entry.parentId) : undefined;\n\t\t}\n\t\treturn history.reverse();\n\t}\n\n\tprivate getUserInputText(message: AgentMessage): string | undefined {\n\t\tif (message.role !== \"user\") return undefined;\n\t\tconst content = message.content;\n\t\tif (typeof content === \"string\") {\n\t\t\tconst text = content.trim();\n\t\t\treturn text.length > 0 ? text : undefined;\n\t\t}\n\t\tconst text = content\n\t\t\t.filter((part): part is TextContent => part.type === \"text\")\n\t\t\t.map((part) => part.text)\n\t\t\t.join(\"\")\n\t\t\t.trim();\n\t\treturn text.length > 0 ? text : undefined;\n\t}\n\n\t/**\n\t * Get the session as a tree structure. Returns a shallow defensive copy of all entries.\n\t * A well-formed session has exactly one root (first entry with parentId === null).\n\t * Orphaned entries (broken parent chain) are also returned as roots.\n\t */\n\tgetTree(): SessionTreeNode[] {\n\t\tconst entries = this.getEntries();\n\t\tconst nodeMap = new Map<string, SessionTreeNode>();\n\t\tconst roots: SessionTreeNode[] = [];\n\n\t\t// Create nodes with resolved labels\n\t\tfor (const entry of entries) {\n\t\t\tconst label = this.labelsById.get(entry.id);\n\t\t\tconst labelTimestamp = this.labelTimestampsById.get(entry.id);\n\t\t\tnodeMap.set(entry.id, { entry, children: [], label, labelTimestamp });\n\t\t}\n\n\t\t// Build tree\n\t\tfor (const entry of entries) {\n\t\t\tconst node = nodeMap.get(entry.id)!;\n\t\t\tif (entry.parentId === null || entry.parentId === entry.id) {\n\t\t\t\troots.push(node);\n\t\t\t} else {\n\t\t\t\tconst parent = nodeMap.get(entry.parentId);\n\t\t\t\tif (parent) {\n\t\t\t\t\tparent.children.push(node);\n\t\t\t\t} else {\n\t\t\t\t\t// Orphan - treat as root\n\t\t\t\t\troots.push(node);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Sort children by timestamp (oldest first, newest at bottom)\n\t\t// Use iterative approach to avoid stack overflow on deep trees\n\t\tconst stack: SessionTreeNode[] = [...roots];\n\t\twhile (stack.length > 0) {\n\t\t\tconst node = stack.pop()!;\n\t\t\tnode.children.sort((a, b) => new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime());\n\t\t\tstack.push(...node.children);\n\t\t}\n\n\t\treturn roots;\n\t}\n\n\t// =========================================================================\n\t// Branching\n\t// =========================================================================\n\n\t/**\n\t * Start a new branch from an earlier entry.\n\t * Moves the leaf pointer to the specified entry. The next appendXXX() call\n\t * will create a child of that entry, forming a new branch. Existing entries\n\t * are not modified or deleted.\n\t */\n\tbranch(branchFromId: string): void {\n\t\tif (!this.byId.has(branchFromId)) {\n\t\t\tthrow new Error(`Entry ${branchFromId} not found`);\n\t\t}\n\t\tthis.leafId = branchFromId;\n\t}\n\n\t/**\n\t * Reset the leaf pointer to null (before any entries).\n\t * The next appendXXX() call will create a new root entry (parentId = null).\n\t * Use this when navigating to re-edit the first user message.\n\t */\n\tresetLeaf(): void {\n\t\tthis.leafId = null;\n\t}\n\n\t/**\n\t * Start a new branch with a summary of the abandoned path.\n\t * Same as branch(), but also appends a branch_summary entry that captures\n\t * context from the abandoned conversation path.\n\t */\n\tbranchWithSummary(\n\t\tbranchFromId: string | null,\n\t\tsummary: string,\n\t\tdetails?: unknown,\n\t\tfromHook?: boolean,\n\t\tusage?: Usage,\n\t): string {\n\t\tif (branchFromId !== null && !this.byId.has(branchFromId)) {\n\t\t\tthrow new Error(`Entry ${branchFromId} not found`);\n\t\t}\n\t\tthis.leafId = branchFromId;\n\t\tconst entry: BranchSummaryEntry = {\n\t\t\ttype: \"branch_summary\",\n\t\t\tid: generateId(this.byId),\n\t\t\tparentId: branchFromId,\n\t\t\ttimestamp: new Date().toISOString(),\n\t\t\tfromId: branchFromId ?? \"root\",\n\t\t\tsummary,\n\t\t\tdetails,\n\t\t\tusage,\n\t\t\tfromHook,\n\t\t};\n\t\tthis._appendEntry(entry);\n\t\treturn entry.id;\n\t}\n\n\t/**\n\t * Create an independent session manager containing only the path from root to the specified\n\t * leaf. The source manager is not mutated, so callers can prepare a replacement runtime before\n\t * retiring the current one.\n\t */\n\tcreateBranchedSessionManager(leafId: string): SessionManager {\n\t\tconst previousSessionFile = this.sessionFile;\n\t\tconst path = this.getBranch(leafId);\n\t\tif (path.length === 0) {\n\t\t\tthrow new Error(`Entry ${leafId} not found`);\n\t\t}\n\n\t\t// Filter out LabelEntry from path - we'll recreate them from the resolved map\n\t\tconst pathWithoutLabels = path.filter((e) => e.type !== \"label\");\n\n\t\tconst newSessionId = createSessionId();\n\t\tconst timestamp = new Date().toISOString();\n\t\tconst fileTimestamp = timestamp.replace(/[:.]/g, \"-\");\n\t\tconst newSessionFile = join(this.getSessionDir(), `${fileTimestamp}_${newSessionId}.jsonl`);\n\n\t\tconst header: SessionHeader = {\n\t\t\ttype: \"session\",\n\t\t\tversion: CURRENT_SESSION_VERSION,\n\t\t\tid: newSessionId,\n\t\t\ttimestamp,\n\t\t\tcwd: this.cwd,\n\t\t\tparentSession: this.persist ? previousSessionFile : undefined,\n\t\t};\n\n\t\t// Collect labels for entries in the path\n\t\tconst pathEntryIds = new Set(pathWithoutLabels.map((e) => e.id));\n\t\tconst labelsToWrite: Array<{ targetId: string; label: string; timestamp: string }> = [];\n\t\tfor (const [targetId, label] of this.labelsById) {\n\t\t\tif (pathEntryIds.has(targetId)) {\n\t\t\t\tlabelsToWrite.push({ targetId, label, timestamp: this.labelTimestampsById.get(targetId)! });\n\t\t\t}\n\t\t}\n\n\t\t// Label entries belong to the copied branch, not the source manager.\n\t\tconst labelEntries: LabelEntry[] = [];\n\t\tlet parentId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null;\n\t\tfor (const { targetId, label, timestamp: labelTimestamp } of labelsToWrite) {\n\t\t\tconst labelEntry: LabelEntry = {\n\t\t\t\ttype: \"label\",\n\t\t\t\tid: generateId(new Set([...pathEntryIds, ...labelEntries.map((e) => e.id)])),\n\t\t\t\tparentId,\n\t\t\t\ttimestamp: labelTimestamp,\n\t\t\t\ttargetId,\n\t\t\t\tlabel,\n\t\t\t};\n\t\t\tlabelEntries.push(labelEntry);\n\t\t\tparentId = labelEntry.id;\n\t\t}\n\n\t\tconst branched = new SessionManager(this.cwd, this.sessionDir, undefined, this.persist, this.agentDir);\n\t\tbranched.fileEntries = [header, ...pathWithoutLabels, ...labelEntries];\n\t\tbranched.sessionId = newSessionId;\n\t\tbranched.sessionFile = this.persist ? newSessionFile : undefined;\n\t\tbranched.coldPayloadEntryIds.clear();\n\t\tfor (const id of this.coldPayloadEntryIds) {\n\t\t\tif (pathEntryIds.has(id)) branched.coldPayloadEntryIds.add(id);\n\t\t}\n\t\tbranched._buildIndex();\n\n\t\t// Only write the file now if it contains an assistant message. Otherwise defer to _persist(),\n\t\t// matching newSession() and avoiding a duplicate-header write on the first assistant response.\n\t\tconst hasAssistant = branched.fileEntries.some(\n\t\t\t(entry) => entry.type === \"message\" && entry.message.role === \"assistant\",\n\t\t);\n\t\tif (this.persist && hasAssistant) {\n\t\t\t// Keep the source manager active while cold compacted getters serialize into the copy.\n\t\t\tbranched._rewriteFile();\n\t\t\tbranched.flushed = true;\n\t\t} else {\n\t\t\tbranched.flushed = false;\n\t\t}\n\t\treturn branched;\n\t}\n\n\t/**\n\t * Create a branch and make it current on this manager.\n\t *\n\t * Prefer createBranchedSessionManager() when a caller is replacing a live runtime and needs to\n\t * keep this manager intact until the replacement has been constructed successfully.\n\t */\n\tcreateBranchedSession(leafId: string): string | undefined {\n\t\tconst branched = this.createBranchedSessionManager(leafId);\n\t\tthis.sessionId = branched.sessionId;\n\t\tthis.sessionFile = branched.sessionFile;\n\t\tthis.flushed = branched.flushed;\n\t\tthis.fileEntries = branched.fileEntries;\n\t\tthis.entries = branched.entries;\n\t\tthis.byId = branched.byId;\n\t\tthis.labelsById = branched.labelsById;\n\t\tthis.labelTimestampsById = branched.labelTimestampsById;\n\t\tthis.leafId = branched.leafId;\n\t\tthis.coldPayloadEntryIds.clear();\n\t\tfor (const id of branched.coldPayloadEntryIds) this.coldPayloadEntryIds.add(id);\n\t\tthis._resetEntryFileIndex();\n\t\treturn this.sessionFile;\n\t}\n\n\t/**\n\t * Create a new session.\n\t * @param cwd Working directory (stored in session header)\n\t * @param agentDir Agent config dir used to compute the default session dir and default-dir candidates.\n\t * @param sessionDir Optional session directory. If omitted, uses default (<agentDir>/sessions/<encoded-cwd>/).\n\t */\n\tstatic create(cwd: string, agentDir: string, sessionDir?: string, options?: NewSessionOptions): SessionManager {\n\t\tconst dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd, agentDir);\n\t\treturn new SessionManager(cwd, dir, undefined, true, agentDir, options);\n\t}\n\n\t/**\n\t * Open a specific session file.\n\t * @param path Path to session file\n\t * @param agentDir Agent config dir used to compute default-dir candidates for usesDefaultSessionDir().\n\t * @param sessionDir Optional session directory for /new or /branch. If omitted, derives from file's parent.\n\t * @param cwdOverride Optional cwd override instead of the session header cwd.\n\t */\n\tstatic open(path: string, agentDir: string, sessionDir?: string, cwdOverride?: string): SessionManager {\n\t\tconst resolvedPath = resolvePath(path);\n\t\t// Read only the header here; the constructor performs the one full session load.\n\t\tconst header = readSessionHeader(resolvedPath);\n\t\tconst cwd = cwdOverride ?? header?.cwd ?? process.cwd();\n\t\t// If no sessionDir provided, derive from file's parent directory\n\t\tconst dir = sessionDir ? normalizePath(sessionDir) : resolve(resolvedPath, \"..\");\n\t\treturn new SessionManager(cwd, dir, resolvedPath, true, agentDir);\n\t}\n\n\t/**\n\t * Continue the most recent session, or create new if none.\n\t * @param cwd Working directory\n\t * @param agentDir Agent config dir used to compute the default session dir and default-dir candidates.\n\t * @param sessionDir Optional session directory. If omitted, uses default (<agentDir>/sessions/<encoded-cwd>/).\n\t */\n\tstatic continueRecent(cwd: string, agentDir: string, sessionDir?: string): SessionManager {\n\t\tconst dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd, agentDir);\n\t\tconst defaultDirs = getDefaultSessionDirCandidates(cwd, agentDir);\n\t\tconst filterCwd = sessionDir !== undefined && !defaultDirs.includes(dir);\n\t\tconst searchDirs = sessionDir ? [dir] : defaultDirs;\n\t\tconst mostRecent = findMostRecentSessionInDirs(searchDirs, filterCwd ? cwd : undefined);\n\t\tif (mostRecent) {\n\t\t\treturn new SessionManager(cwd, resolve(mostRecent, \"..\"), mostRecent, true, agentDir);\n\t\t}\n\t\treturn new SessionManager(cwd, dir, undefined, true, agentDir);\n\t}\n\n\t/** Create an in-memory session (no file persistence). No agent dir applies (usesDefaultSessionDir is false). */\n\tstatic inMemory(cwd: string = process.cwd()): SessionManager {\n\t\treturn new SessionManager(cwd, \"\", undefined, false, \"\");\n\t}\n\n\t/**\n\t * Fork a session from another project directory into the current project.\n\t * Creates a new session in the target cwd with the full history from the source session.\n\t * @param sourcePath Path to the source session file\n\t * @param targetCwd Target working directory (where the new session will be stored)\n\t * @param agentDir Agent config dir used to compute the default session dir for targetCwd.\n\t * @param sessionDir Optional session directory. If omitted, uses default for targetCwd.\n\t */\n\tstatic forkFrom(\n\t\tsourcePath: string,\n\t\ttargetCwd: string,\n\t\tagentDir: string,\n\t\tsessionDir?: string,\n\t\toptions?: NewSessionOptions,\n\t): SessionManager {\n\t\tconst resolvedSourcePath = resolvePath(sourcePath);\n\t\tconst resolvedTargetCwd = resolvePath(targetCwd);\n\t\tconst sourceEntries = loadEntriesFromFile(resolvedSourcePath);\n\t\tif (sourceEntries.length === 0) {\n\t\t\tthrow new Error(`Cannot fork: source session file is empty or invalid: ${resolvedSourcePath}`);\n\t\t}\n\n\t\tconst sourceHeader = sourceEntries.find((e) => e.type === \"session\") as SessionHeader | undefined;\n\t\tif (!sourceHeader) {\n\t\t\tthrow new Error(`Cannot fork: source session has no header: ${resolvedSourcePath}`);\n\t\t}\n\n\t\tconst dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(resolvedTargetCwd, agentDir);\n\t\tif (!existsSync(dir)) {\n\t\t\tmkdirSync(dir, { recursive: true });\n\t\t}\n\n\t\t// Create new session file with new ID but forked content\n\t\tif (options?.id !== undefined) {\n\t\t\tassertValidSessionId(options.id);\n\t\t}\n\t\tconst newSessionId = options?.id ?? createSessionId();\n\t\tconst timestamp = new Date().toISOString();\n\t\tconst fileTimestamp = timestamp.replace(/[:.]/g, \"-\");\n\t\tconst newSessionFile = join(dir, `${fileTimestamp}_${newSessionId}.jsonl`);\n\n\t\t// Write new header pointing to source as parent, with updated cwd\n\t\tconst newHeader: SessionHeader = {\n\t\t\ttype: \"session\",\n\t\t\tversion: CURRENT_SESSION_VERSION,\n\t\t\tid: newSessionId,\n\t\t\ttimestamp,\n\t\t\tcwd: resolvedTargetCwd,\n\t\t\tparentSession: resolvedSourcePath,\n\t\t};\n\t\twriteFileSync(newSessionFile, `${JSON.stringify(newHeader)}\\n`, { flag: \"wx\" });\n\n\t\t// Copy all non-header entries from source\n\t\tfor (const entry of sourceEntries) {\n\t\t\tif (entry.type !== \"session\") {\n\t\t\t\tappendFileSync(newSessionFile, `${JSON.stringify(entry)}\\n`);\n\t\t\t}\n\t\t}\n\n\t\treturn new SessionManager(resolvedTargetCwd, dir, newSessionFile, true, agentDir);\n\t}\n\n\t/**\n\t * List all sessions for a directory.\n\t * @param cwd Working directory (used to compute default session directory)\n\t * @param agentDir Agent config dir used to compute the default session dir and default-dir candidates.\n\t * @param sessionDir Optional session directory. If omitted, uses default (<agentDir>/sessions/<encoded-cwd>/).\n\t * @param onProgress Optional callback for progress updates (loaded, total)\n\t */\n\tstatic async list(\n\t\tcwd: string,\n\t\tagentDir: string,\n\t\tsessionDir?: string,\n\t\tonProgress?: SessionListProgress,\n\t): Promise<SessionInfo[]> {\n\t\tconst dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd, agentDir);\n\t\tconst defaultDirs = getDefaultSessionDirCandidates(cwd, agentDir);\n\t\tconst filterCwd = sessionDir !== undefined && !defaultDirs.includes(dir);\n\t\tconst resolvedCwd = resolvePath(cwd);\n\t\tconst dirs = sessionDir ? [dir] : defaultDirs;\n\t\tconst progressCounts = await Promise.all(dirs.map((candidateDir) => countSessionFiles(candidateDir)));\n\t\tconst progressTotal = progressCounts.reduce((sum, count) => sum + count, 0);\n\t\tconst allSessions: SessionInfo[] = [];\n\t\tlet progressOffset = 0;\n\t\tfor (let i = 0; i < dirs.length; i++) {\n\t\t\tconst candidateDir = dirs[i];\n\t\t\tconst sessions = (await listSessionsFromDir(candidateDir, onProgress, progressOffset, progressTotal)).filter(\n\t\t\t\t(session) => !filterCwd || sessionCwdMatches(session.cwd, resolvedCwd),\n\t\t\t);\n\t\t\tprogressOffset += progressCounts[i];\n\t\t\tallSessions.push(...sessions);\n\t\t}\n\t\tallSessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());\n\t\treturn allSessions;\n\t}\n\n\t/**\n\t * List all sessions across all project directories under the sessions root.\n\t * @param sessionsDir Sessions root dir to scan for per-project subdirectories (e.g. <agentDir>/sessions).\n\t * @param customSessionDir Optional single directory to list flat instead of scanning the root.\n\t * @param onProgress Optional callback for progress updates (loaded, total)\n\t */\n\tstatic async listAll(sessionsDir: string, onProgress?: SessionListProgress): Promise<SessionInfo[]>;\n\tstatic async listAll(\n\t\tsessionsDir: string,\n\t\tcustomSessionDir?: string,\n\t\tonProgress?: SessionListProgress,\n\t): Promise<SessionInfo[]>;\n\tstatic async listAll(\n\t\tsessionsDir: string,\n\t\tcustomSessionDirOrOnProgress?: string | SessionListProgress,\n\t\tonProgress?: SessionListProgress,\n\t): Promise<SessionInfo[]> {\n\t\tconst customSessionDir =\n\t\t\ttypeof customSessionDirOrOnProgress === \"string\" ? normalizePath(customSessionDirOrOnProgress) : undefined;\n\t\tconst progress = typeof customSessionDirOrOnProgress === \"function\" ? customSessionDirOrOnProgress : onProgress;\n\t\tif (customSessionDir) {\n\t\t\tconst sessions = await listSessionsFromDir(customSessionDir, progress);\n\t\t\tsessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());\n\t\t\treturn sessions;\n\t\t}\n\n\t\tconst resolvedSessionsDir = normalizePath(sessionsDir);\n\n\t\ttry {\n\t\t\tif (!existsSync(resolvedSessionsDir)) {\n\t\t\t\treturn [];\n\t\t\t}\n\t\t\tconst entries = await readdir(resolvedSessionsDir, { withFileTypes: true });\n\t\t\tconst dirs = entries.filter((e) => e.isDirectory()).map((e) => join(resolvedSessionsDir, e.name));\n\n\t\t\t// Count total files first for accurate progress\n\t\t\tlet totalFiles = 0;\n\t\t\tconst dirFiles: string[][] = [];\n\t\t\tfor (const dir of dirs) {\n\t\t\t\ttry {\n\t\t\t\t\tconst files = (await readdir(dir)).filter((f) => f.endsWith(\".jsonl\"));\n\t\t\t\t\tdirFiles.push(files.map((f) => join(dir, f)));\n\t\t\t\t\ttotalFiles += files.length;\n\t\t\t\t} catch {\n\t\t\t\t\tdirFiles.push([]);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Process all files with progress tracking\n\t\t\tlet loaded = 0;\n\t\t\tconst sessions: SessionInfo[] = [];\n\t\t\tconst allFiles = dirFiles.flat();\n\n\t\t\tconst results = await buildSessionInfosWithConcurrency(allFiles, () => {\n\t\t\t\tloaded++;\n\t\t\t\tprogress?.(loaded, totalFiles);\n\t\t\t});\n\n\t\t\tfor (const info of results) {\n\t\t\t\tif (info) {\n\t\t\t\t\tsessions.push(info);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tsessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());\n\t\t\treturn sessions;\n\t\t} catch {\n\t\t\treturn [];\n\t\t}\n\t}\n}\n"]}
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { appendFileSync, closeSync, createReadStream, existsSync, mkdirSync, openSync, readdirSync, readSync, renameSync, statSync, writeFileSync, } from "node:fs";
3
3
  import { readdir, stat } from "node:fs/promises";
4
- import { join, resolve } from "node:path";
4
+ import { dirname, join, resolve } from "node:path";
5
5
  import { createInterface } from "node:readline";
6
6
  import { StringDecoder } from "node:string_decoder";
7
7
  import { createBranchSummaryMessage, createCompactionSummaryMessage, createCustomMessage, } from "../messages.js";
@@ -220,8 +220,9 @@ export function buildSessionContext(entries, leafId, byId) {
220
220
  return { messages, thinkingLevel, model };
221
221
  }
222
222
  /**
223
- * Compute the default session directory for a cwd.
224
- * Encodes cwd into a safe directory name under ~/.pi/agent/sessions/.
223
+ * Compute the default session directory for a cwd without creating it.
224
+ * Encodes cwd into a safe directory name under ~/.pi/agent/sessions/; persistence creates the
225
+ * parent only when the first durable response is written.
225
226
  */
226
227
  function getEncodedSessionDirName(cwd) {
227
228
  return `--${encodeURIComponent(resolvePath(cwd))}--`;
@@ -242,11 +243,7 @@ function getDefaultSessionDirCandidates(cwd, agentDir) {
242
243
  return current === legacy ? [current] : [current, legacy];
243
244
  }
244
245
  export function getDefaultSessionDir(cwd, agentDir) {
245
- const sessionDir = getDefaultSessionDirPath(cwd, agentDir);
246
- if (!existsSync(sessionDir)) {
247
- mkdirSync(sessionDir, { recursive: true });
248
- }
249
- return sessionDir;
246
+ return getDefaultSessionDirPath(cwd, agentDir);
250
247
  }
251
248
  const SESSION_READ_BUFFER_SIZE = 1024 * 1024;
252
249
  // One line must fit comfortably under V8's max string length (~512MiB chars).
@@ -682,9 +679,6 @@ export class SessionManager {
682
679
  this.sessionDir = normalizePath(sessionDir);
683
680
  this.agentDir = agentDir;
684
681
  this.persist = persist;
685
- if (persist && this.sessionDir && !existsSync(this.sessionDir)) {
686
- mkdirSync(this.sessionDir, { recursive: true });
687
- }
688
682
  if (sessionFile) {
689
683
  this.setSessionFile(sessionFile);
690
684
  }
@@ -791,9 +785,15 @@ export class SessionManager {
791
785
  this.coldPayloadEntryIds.delete(id);
792
786
  }
793
787
  }
788
+ _ensureSessionFileParent(targetFile) {
789
+ const parentDir = dirname(targetFile);
790
+ if (!existsSync(parentDir))
791
+ mkdirSync(parentDir, { recursive: true });
792
+ }
794
793
  _rewriteFile(targetFile = this.sessionFile) {
795
794
  if (!this.persist || !targetFile)
796
795
  return;
796
+ this._ensureSessionFileParent(targetFile);
797
797
  // Write-then-rename keeps the session file valid at every instant: truncating
798
798
  // in place leaves a torn-file window for crashes or a concurrent process
799
799
  // appending to the same session.
@@ -961,6 +961,7 @@ export class SessionManager {
961
961
  const hasAssistant = this.fileEntries.some((e) => e.type === "message" && e.message.role === "assistant");
962
962
  if (!hasAssistant) {
963
963
  if (this.flushed) {
964
+ this._ensureSessionFileParent(this.sessionFile);
964
965
  appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);
965
966
  }
966
967
  else {
@@ -970,6 +971,7 @@ export class SessionManager {
970
971
  return;
971
972
  }
972
973
  if (!this.flushed) {
974
+ this._ensureSessionFileParent(this.sessionFile);
973
975
  const fd = openSync(this.sessionFile, "wx");
974
976
  try {
975
977
  for (const e of this.fileEntries) {
@@ -982,6 +984,7 @@ export class SessionManager {
982
984
  this.flushed = true;
983
985
  }
984
986
  else {
987
+ this._ensureSessionFileParent(this.sessionFile);
985
988
  appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);
986
989
  }
987
990
  }
@@ -1035,7 +1038,7 @@ export class SessionManager {
1035
1038
  return entry.id;
1036
1039
  }
1037
1040
  /** Append a compaction summary as child of current leaf, then advance leaf. Returns entry id. */
1038
- appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromHook) {
1041
+ appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromHook, usage) {
1039
1042
  const compactionParentId = this.leafId;
1040
1043
  const entry = {
1041
1044
  type: "compaction",
@@ -1046,6 +1049,7 @@ export class SessionManager {
1046
1049
  firstKeptEntryId,
1047
1050
  tokensBefore,
1048
1051
  details,
1052
+ usage,
1049
1053
  fromHook,
1050
1054
  };
1051
1055
  this._appendEntry(entry);
@@ -1339,7 +1343,7 @@ export class SessionManager {
1339
1343
  * Same as branch(), but also appends a branch_summary entry that captures
1340
1344
  * context from the abandoned conversation path.
1341
1345
  */
1342
- branchWithSummary(branchFromId, summary, details, fromHook) {
1346
+ branchWithSummary(branchFromId, summary, details, fromHook, usage) {
1343
1347
  if (branchFromId !== null && !this.byId.has(branchFromId)) {
1344
1348
  throw new Error(`Entry ${branchFromId} not found`);
1345
1349
  }
@@ -1352,17 +1356,18 @@ export class SessionManager {
1352
1356
  fromId: branchFromId ?? "root",
1353
1357
  summary,
1354
1358
  details,
1359
+ usage,
1355
1360
  fromHook,
1356
1361
  };
1357
1362
  this._appendEntry(entry);
1358
1363
  return entry.id;
1359
1364
  }
1360
1365
  /**
1361
- * Create a new session file containing only the path from root to the specified leaf.
1362
- * Useful for extracting a single conversation path from a branched session.
1363
- * Returns the new session file path, or undefined if not persisting.
1366
+ * Create an independent session manager containing only the path from root to the specified
1367
+ * leaf. The source manager is not mutated, so callers can prepare a replacement runtime before
1368
+ * retiring the current one.
1364
1369
  */
1365
- createBranchedSession(leafId) {
1370
+ createBranchedSessionManager(leafId) {
1366
1371
  const previousSessionFile = this.sessionFile;
1367
1372
  const path = this.getBranch(leafId);
1368
1373
  if (path.length === 0) {
@@ -1390,46 +1395,7 @@ export class SessionManager {
1390
1395
  labelsToWrite.push({ targetId, label, timestamp: this.labelTimestampsById.get(targetId) });
1391
1396
  }
1392
1397
  }
1393
- if (this.persist) {
1394
- // Build label entries
1395
- const lastEntryId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null;
1396
- let parentId = lastEntryId;
1397
- const labelEntries = [];
1398
- for (const { targetId, label, timestamp: labelTimestamp } of labelsToWrite) {
1399
- const labelEntry = {
1400
- type: "label",
1401
- id: generateId(new Set(pathEntryIds)),
1402
- parentId,
1403
- timestamp: labelTimestamp,
1404
- targetId,
1405
- label,
1406
- };
1407
- pathEntryIds.add(labelEntry.id);
1408
- labelEntries.push(labelEntry);
1409
- parentId = labelEntry.id;
1410
- }
1411
- this.fileEntries = [header, ...pathWithoutLabels, ...labelEntries];
1412
- this.sessionId = newSessionId;
1413
- this._buildIndex();
1414
- // Only write the file now if it contains an assistant message.
1415
- // Otherwise defer to _persist(), which creates the file on the
1416
- // first assistant response, matching the newSession() contract
1417
- // and avoiding the duplicate-header bug when _persist()'s
1418
- // no-assistant guard later resets flushed to false.
1419
- const hasAssistant = this.fileEntries.some((e) => e.type === "message" && e.message.role === "assistant");
1420
- if (hasAssistant) {
1421
- // Keep the source file active while cold compacted getters are serialized into the branch.
1422
- this._rewriteFile(newSessionFile);
1423
- this.flushed = true;
1424
- }
1425
- else {
1426
- this.flushed = false;
1427
- }
1428
- this.sessionFile = newSessionFile;
1429
- this._resetEntryFileIndex();
1430
- return newSessionFile;
1431
- }
1432
- // In-memory mode: replace current session with the path + labels
1398
+ // Label entries belong to the copied branch, not the source manager.
1433
1399
  const labelEntries = [];
1434
1400
  let parentId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null;
1435
1401
  for (const { targetId, label, timestamp: labelTimestamp } of labelsToWrite) {
@@ -1444,10 +1410,51 @@ export class SessionManager {
1444
1410
  labelEntries.push(labelEntry);
1445
1411
  parentId = labelEntry.id;
1446
1412
  }
1447
- this.fileEntries = [header, ...pathWithoutLabels, ...labelEntries];
1448
- this.sessionId = newSessionId;
1449
- this._buildIndex();
1450
- return undefined;
1413
+ const branched = new SessionManager(this.cwd, this.sessionDir, undefined, this.persist, this.agentDir);
1414
+ branched.fileEntries = [header, ...pathWithoutLabels, ...labelEntries];
1415
+ branched.sessionId = newSessionId;
1416
+ branched.sessionFile = this.persist ? newSessionFile : undefined;
1417
+ branched.coldPayloadEntryIds.clear();
1418
+ for (const id of this.coldPayloadEntryIds) {
1419
+ if (pathEntryIds.has(id))
1420
+ branched.coldPayloadEntryIds.add(id);
1421
+ }
1422
+ branched._buildIndex();
1423
+ // Only write the file now if it contains an assistant message. Otherwise defer to _persist(),
1424
+ // matching newSession() and avoiding a duplicate-header write on the first assistant response.
1425
+ const hasAssistant = branched.fileEntries.some((entry) => entry.type === "message" && entry.message.role === "assistant");
1426
+ if (this.persist && hasAssistant) {
1427
+ // Keep the source manager active while cold compacted getters serialize into the copy.
1428
+ branched._rewriteFile();
1429
+ branched.flushed = true;
1430
+ }
1431
+ else {
1432
+ branched.flushed = false;
1433
+ }
1434
+ return branched;
1435
+ }
1436
+ /**
1437
+ * Create a branch and make it current on this manager.
1438
+ *
1439
+ * Prefer createBranchedSessionManager() when a caller is replacing a live runtime and needs to
1440
+ * keep this manager intact until the replacement has been constructed successfully.
1441
+ */
1442
+ createBranchedSession(leafId) {
1443
+ const branched = this.createBranchedSessionManager(leafId);
1444
+ this.sessionId = branched.sessionId;
1445
+ this.sessionFile = branched.sessionFile;
1446
+ this.flushed = branched.flushed;
1447
+ this.fileEntries = branched.fileEntries;
1448
+ this.entries = branched.entries;
1449
+ this.byId = branched.byId;
1450
+ this.labelsById = branched.labelsById;
1451
+ this.labelTimestampsById = branched.labelTimestampsById;
1452
+ this.leafId = branched.leafId;
1453
+ this.coldPayloadEntryIds.clear();
1454
+ for (const id of branched.coldPayloadEntryIds)
1455
+ this.coldPayloadEntryIds.add(id);
1456
+ this._resetEntryFileIndex();
1457
+ return this.sessionFile;
1451
1458
  }
1452
1459
  /**
1453
1460
  * Create a new session.