@earendil-works/pi-coding-agent 0.75.5 → 0.77.0

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 (119) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +4 -0
  3. package/dist/cli/args.d.ts +2 -0
  4. package/dist/cli/args.d.ts.map +1 -1
  5. package/dist/cli/args.js +15 -0
  6. package/dist/cli/args.js.map +1 -1
  7. package/dist/config.d.ts.map +1 -1
  8. package/dist/config.js +15 -2
  9. package/dist/config.js.map +1 -1
  10. package/dist/core/agent-session-services.d.ts +1 -0
  11. package/dist/core/agent-session-services.d.ts.map +1 -1
  12. package/dist/core/agent-session-services.js +1 -0
  13. package/dist/core/agent-session-services.js.map +1 -1
  14. package/dist/core/agent-session.d.ts +5 -1
  15. package/dist/core/agent-session.d.ts.map +1 -1
  16. package/dist/core/agent-session.js +28 -4
  17. package/dist/core/agent-session.js.map +1 -1
  18. package/dist/core/compaction/compaction.d.ts.map +1 -1
  19. package/dist/core/compaction/compaction.js +18 -24
  20. package/dist/core/compaction/compaction.js.map +1 -1
  21. package/dist/core/extensions/runner.d.ts +1 -1
  22. package/dist/core/extensions/runner.d.ts.map +1 -1
  23. package/dist/core/extensions/runner.js +8 -2
  24. package/dist/core/extensions/runner.js.map +1 -1
  25. package/dist/core/extensions/types.d.ts +7 -5
  26. package/dist/core/extensions/types.d.ts.map +1 -1
  27. package/dist/core/extensions/types.js.map +1 -1
  28. package/dist/core/model-registry.d.ts.map +1 -1
  29. package/dist/core/model-registry.js +65 -13
  30. package/dist/core/model-registry.js.map +1 -1
  31. package/dist/core/model-resolver.d.ts.map +1 -1
  32. package/dist/core/model-resolver.js +1 -1
  33. package/dist/core/model-resolver.js.map +1 -1
  34. package/dist/core/output-guard.d.ts +1 -0
  35. package/dist/core/output-guard.d.ts.map +1 -1
  36. package/dist/core/output-guard.js +52 -22
  37. package/dist/core/output-guard.js.map +1 -1
  38. package/dist/core/package-manager.d.ts.map +1 -1
  39. package/dist/core/package-manager.js +16 -4
  40. package/dist/core/package-manager.js.map +1 -1
  41. package/dist/core/resolve-config-value.d.ts +9 -1
  42. package/dist/core/resolve-config-value.d.ts.map +1 -1
  43. package/dist/core/resolve-config-value.js +134 -11
  44. package/dist/core/resolve-config-value.js.map +1 -1
  45. package/dist/core/sdk.d.ts +2 -0
  46. package/dist/core/sdk.d.ts.map +1 -1
  47. package/dist/core/sdk.js +10 -6
  48. package/dist/core/sdk.js.map +1 -1
  49. package/dist/core/session-manager.d.ts +6 -7
  50. package/dist/core/session-manager.d.ts.map +1 -1
  51. package/dist/core/session-manager.js +75 -28
  52. package/dist/core/session-manager.js.map +1 -1
  53. package/dist/core/settings-manager.d.ts +2 -0
  54. package/dist/core/settings-manager.d.ts.map +1 -1
  55. package/dist/core/settings-manager.js +14 -9
  56. package/dist/core/settings-manager.js.map +1 -1
  57. package/dist/core/system-prompt.d.ts.map +1 -1
  58. package/dist/core/system-prompt.js +0 -3
  59. package/dist/core/system-prompt.js.map +1 -1
  60. package/dist/main.d.ts.map +1 -1
  61. package/dist/main.js +61 -16
  62. package/dist/main.js.map +1 -1
  63. package/dist/migrations.d.ts.map +1 -1
  64. package/dist/migrations.js +118 -1
  65. package/dist/migrations.js.map +1 -1
  66. package/dist/modes/interactive/components/login-dialog.d.ts +1 -3
  67. package/dist/modes/interactive/components/login-dialog.d.ts.map +1 -1
  68. package/dist/modes/interactive/components/login-dialog.js +2 -4
  69. package/dist/modes/interactive/components/login-dialog.js.map +1 -1
  70. package/dist/modes/interactive/components/user-message.d.ts.map +1 -1
  71. package/dist/modes/interactive/components/user-message.js +1 -1
  72. package/dist/modes/interactive/components/user-message.js.map +1 -1
  73. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  74. package/dist/modes/interactive/interactive-mode.js +25 -6
  75. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  76. package/dist/modes/interactive/theme/theme.d.ts.map +1 -1
  77. package/dist/modes/interactive/theme/theme.js +10 -0
  78. package/dist/modes/interactive/theme/theme.js.map +1 -1
  79. package/dist/modes/rpc/rpc-client.d.ts +3 -0
  80. package/dist/modes/rpc/rpc-client.d.ts.map +1 -1
  81. package/dist/modes/rpc/rpc-client.js +64 -7
  82. package/dist/modes/rpc/rpc-client.js.map +1 -1
  83. package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  84. package/dist/modes/rpc/rpc-mode.js +18 -4
  85. package/dist/modes/rpc/rpc-mode.js.map +1 -1
  86. package/dist/modes/rpc/rpc-types.d.ts +1 -0
  87. package/dist/modes/rpc/rpc-types.d.ts.map +1 -1
  88. package/dist/modes/rpc/rpc-types.js.map +1 -1
  89. package/dist/utils/deprecation.d.ts +4 -0
  90. package/dist/utils/deprecation.d.ts.map +1 -0
  91. package/dist/utils/deprecation.js +13 -0
  92. package/dist/utils/deprecation.js.map +1 -0
  93. package/dist/utils/json.d.ts +3 -0
  94. package/dist/utils/json.d.ts.map +1 -0
  95. package/dist/utils/json.js +7 -0
  96. package/dist/utils/json.js.map +1 -0
  97. package/docs/custom-provider.md +13 -10
  98. package/docs/development.md +1 -1
  99. package/docs/extensions.md +12 -6
  100. package/docs/models.md +25 -12
  101. package/docs/providers.md +13 -5
  102. package/docs/sdk.md +6 -0
  103. package/docs/settings.md +6 -2
  104. package/docs/terminal-setup.md +6 -0
  105. package/docs/usage.md +4 -0
  106. package/examples/extensions/README.md +1 -0
  107. package/examples/extensions/custom-provider-anthropic/index.ts +1 -1
  108. package/examples/extensions/custom-provider-anthropic/package-lock.json +2 -2
  109. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  110. package/examples/extensions/custom-provider-gitlab-duo/index.ts +1 -1
  111. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  112. package/examples/extensions/git-merge-and-resolve.ts +115 -0
  113. package/examples/extensions/input-transform-streaming.ts +39 -0
  114. package/examples/extensions/sandbox/package-lock.json +2 -2
  115. package/examples/extensions/sandbox/package.json +1 -1
  116. package/examples/extensions/with-deps/package-lock.json +2 -2
  117. package/examples/extensions/with-deps/package.json +1 -1
  118. package/npm-shrinkwrap.json +71 -56
  119. package/package.json +6 -6
@@ -134,6 +134,7 @@ export interface SessionInfo {
134
134
  allMessagesText: string;
135
135
  }
136
136
  export type ReadonlySessionManager = Pick<SessionManager, "getCwd" | "getSessionDir" | "getSessionId" | "getSessionFile" | "getLeafId" | "getLeafEntry" | "getEntry" | "getLabel" | "getBranch" | "getHeader" | "getEntries" | "getTree" | "getSessionName">;
137
+ export declare function assertValidSessionId(id: string): void;
137
138
  /** Exported for testing */
138
139
  export declare function migrateSessionEntries(entries: FileEntry[]): void;
139
140
  /** Exported for compaction.test.ts */
@@ -145,15 +146,11 @@ export declare function getLatestCompactionEntry(entries: SessionEntry[]): Compa
145
146
  * Handles compaction and branch summaries along the path.
146
147
  */
147
148
  export declare function buildSessionContext(entries: SessionEntry[], leafId?: string | null, byId?: Map<string, SessionEntry>): SessionContext;
148
- /**
149
- * Compute the default session directory for a cwd.
150
- * Encodes cwd into a safe directory name under ~/.pi/agent/sessions/.
151
- */
152
149
  export declare function getDefaultSessionDir(cwd: string, agentDir?: string): string;
153
150
  /** Exported for testing */
154
151
  export declare function loadEntriesFromFile(filePath: string): FileEntry[];
155
152
  /** Exported for testing */
156
- export declare function findMostRecentSession(sessionDir: string): string | null;
153
+ export declare function findMostRecentSession(sessionDir: string, cwd?: string): string | null;
157
154
  export type SessionListProgress = (loaded: number, total: number) => void;
158
155
  /**
159
156
  * Manages conversation sessions as append-only trees stored in JSONL files.
@@ -187,6 +184,7 @@ export declare class SessionManager {
187
184
  isPersisted(): boolean;
188
185
  getCwd(): string;
189
186
  getSessionDir(): string;
187
+ usesDefaultSessionDir(): boolean;
190
188
  getSessionId(): string;
191
189
  getSessionFile(): string | undefined;
192
190
  _persist(entry: SessionEntry): void;
@@ -293,7 +291,7 @@ export declare class SessionManager {
293
291
  * @param cwd Working directory (stored in session header)
294
292
  * @param sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions/<encoded-cwd>/).
295
293
  */
296
- static create(cwd: string, sessionDir?: string): SessionManager;
294
+ static create(cwd: string, sessionDir?: string, options?: NewSessionOptions): SessionManager;
297
295
  /**
298
296
  * Open a specific session file.
299
297
  * @param path Path to session file
@@ -316,7 +314,7 @@ export declare class SessionManager {
316
314
  * @param targetCwd Target working directory (where the new session will be stored)
317
315
  * @param sessionDir Optional session directory. If omitted, uses default for targetCwd.
318
316
  */
319
- static forkFrom(sourcePath: string, targetCwd: string, sessionDir?: string): SessionManager;
317
+ static forkFrom(sourcePath: string, targetCwd: string, sessionDir?: string, options?: NewSessionOptions): SessionManager;
320
318
  /**
321
319
  * List all sessions for a directory.
322
320
  * @param cwd Working directory (used to compute default session directory)
@@ -329,5 +327,6 @@ export declare class SessionManager {
329
327
  * @param onProgress Optional callback for progress updates (loaded, total)
330
328
  */
331
329
  static listAll(onProgress?: SessionListProgress): Promise<SessionInfo[]>;
330
+ static listAll(sessionDir?: string, onProgress?: SessionListProgress): Promise<SessionInfo[]>;
332
331
  }
333
332
  //# sourceMappingURL=session-manager.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"session-manager.d.ts","sourceRoot":"","sources":["../../src/core/session-manager.ts"],"names":[],"mappings":"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","sourcesContent":["import { type AgentMessage, uuidv7 } from \"@earendil-works/pi-agent-core\";\nimport type { ImageContent, Message, TextContent } from \"@earendil-works/pi-ai\";\nimport { randomUUID } from \"crypto\";\nimport {\n\tappendFileSync,\n\tcloseSync,\n\texistsSync,\n\tmkdirSync,\n\topenSync,\n\treaddirSync,\n\treadFileSync,\n\treadSync,\n\tstatSync,\n\twriteFileSync,\n} from \"fs\";\nimport { readdir, readFile, stat } from \"fs/promises\";\nimport { join, resolve } from \"path\";\nimport { getAgentDir as getDefaultAgentDir, getSessionsDir } from \"../config.ts\";\nimport { normalizePath, resolvePath } from \"../utils/paths.ts\";\nimport {\n\ttype BashExecutionMessage,\n\ttype CustomMessage,\n\tcreateBranchSummaryMessage,\n\tcreateCompactionSummaryMessage,\n\tcreateCustomMessage,\n} from \"./messages.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| \"getHeader\"\n\t| \"getEntries\"\n\t| \"getTree\"\n\t| \"getSessionName\"\n>;\n\nfunction createSessionId(): string {\n\treturn uuidv7();\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\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, collecting path\n\tconst path: SessionEntry[] = [];\n\tlet current: SessionEntry | undefined = leaf;\n\twhile (current) {\n\t\tpath.unshift(current);\n\t\tcurrent = current.parentId ? byId.get(current.parentId) : undefined;\n\t}\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 */\nexport function getDefaultSessionDir(cwd: string, agentDir: string = getDefaultAgentDir()): string {\n\tconst resolvedCwd = resolvePath(cwd);\n\tconst resolvedAgentDir = resolvePath(agentDir);\n\tconst safePath = `--${resolvedCwd.replace(/^[/\\\\]/, \"\").replace(/[/\\\\:]/g, \"-\")}--`;\n\tconst sessionDir = join(resolvedAgentDir, \"sessions\", safePath);\n\tif (!existsSync(sessionDir)) {\n\t\tmkdirSync(sessionDir, { recursive: true });\n\t}\n\treturn sessionDir;\n}\n\n/** Exported for testing */\nexport function loadEntriesFromFile(filePath: string): FileEntry[] {\n\tconst resolvedFilePath = normalizePath(filePath);\n\tif (!existsSync(resolvedFilePath)) return [];\n\n\tconst content = readFileSync(resolvedFilePath, \"utf8\");\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\t// Validate session header\n\tif (entries.length === 0) return entries;\n\tconst header = entries[0];\n\tif (header.type !== \"session\" || typeof (header as any).id !== \"string\") {\n\t\treturn [];\n\t}\n\n\treturn entries;\n}\n\nfunction isValidSessionFile(filePath: string): boolean {\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 false;\n\t\tconst header = JSON.parse(firstLine);\n\t\treturn header.type === \"session\" && typeof header.id === \"string\";\n\t} catch {\n\t\treturn false;\n\t}\n}\n\n/** Exported for testing */\nexport function findMostRecentSession(sessionDir: string): string | null {\n\tconst resolvedSessionDir = normalizePath(sessionDir);\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.filter(isValidSessionFile)\n\t\t\t.map((path) => ({ path, mtime: statSync(path).mtime }))\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 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\nfunction getLastActivityTime(entries: FileEntry[]): number | undefined {\n\tlet lastActivityTime: number | undefined;\n\n\tfor (const entry of entries) {\n\t\tif (entry.type !== \"message\") continue;\n\n\t\tconst message = (entry as SessionMessageEntry).message;\n\t\tif (!isMessageWithContent(message)) continue;\n\t\tif (message.role !== \"user\" && message.role !== \"assistant\") continue;\n\n\t\tconst msgTimestamp = (message as { timestamp?: number }).timestamp;\n\t\tif (typeof msgTimestamp === \"number\") {\n\t\t\tlastActivityTime = Math.max(lastActivityTime ?? 0, msgTimestamp);\n\t\t\tcontinue;\n\t\t}\n\n\t\tconst entryTimestamp = (entry as SessionEntryBase).timestamp;\n\t\tif (typeof entryTimestamp === \"string\") {\n\t\t\tconst t = new Date(entryTimestamp).getTime();\n\t\t\tif (!Number.isNaN(t)) {\n\t\t\t\tlastActivityTime = Math.max(lastActivityTime ?? 0, t);\n\t\t\t}\n\t\t}\n\t}\n\n\treturn lastActivityTime;\n}\n\nfunction getSessionModifiedDate(entries: FileEntry[], header: SessionHeader, statsMtime: Date): Date {\n\tconst lastActivityTime = getLastActivityTime(entries);\n\tif (typeof lastActivityTime === \"number\" && lastActivityTime > 0) {\n\t\treturn new Date(lastActivityTime);\n\t}\n\n\tconst headerTime = typeof header.timestamp === \"string\" ? new Date(header.timestamp).getTime() : NaN;\n\treturn !Number.isNaN(headerTime) ? new Date(headerTime) : statsMtime;\n}\n\nasync function buildSessionInfo(filePath: string): Promise<SessionInfo | null> {\n\ttry {\n\t\tconst content = await readFile(filePath, \"utf8\");\n\t\tconst entries: FileEntry[] = [];\n\t\tconst lines = content.trim().split(\"\\n\");\n\n\t\tfor (const line of lines) {\n\t\t\tif (!line.trim()) continue;\n\t\t\ttry {\n\t\t\t\tentries.push(JSON.parse(line) as FileEntry);\n\t\t\t} catch {\n\t\t\t\t// Skip malformed lines\n\t\t\t}\n\t\t}\n\n\t\tif (entries.length === 0) return null;\n\t\tconst header = entries[0];\n\t\tif (header.type !== \"session\") return null;\n\n\t\tconst stats = await stat(filePath);\n\t\tlet messageCount = 0;\n\t\tlet firstMessage = \"\";\n\t\tconst allMessages: string[] = [];\n\t\tlet name: string | undefined;\n\n\t\tfor (const entry of entries) {\n\t\t\t// Extract session name (use latest, including explicit clears)\n\t\t\tif (entry.type === \"session_info\") {\n\t\t\t\tconst infoEntry = entry as SessionInfoEntry;\n\t\t\t\tname = infoEntry.name?.trim() || undefined;\n\t\t\t}\n\n\t\t\tif (entry.type !== \"message\") continue;\n\t\t\tmessageCount++;\n\n\t\t\tconst message = (entry as SessionMessageEntry).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\tallMessages.push(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\tconst cwd = typeof (header as SessionHeader).cwd === \"string\" ? (header as SessionHeader).cwd : \"\";\n\t\tconst parentSessionPath = (header as SessionHeader).parentSession;\n\n\t\tconst modified = getSessionModifiedDate(entries, header as SessionHeader, stats.mtime);\n\n\t\treturn {\n\t\t\tpath: filePath,\n\t\t\tid: (header as SessionHeader).id,\n\t\t\tcwd,\n\t\t\tname,\n\t\t\tparentSessionPath,\n\t\t\tcreated: new Date((header as SessionHeader).timestamp),\n\t\t\tmodified,\n\t\t\tmessageCount,\n\t\t\tfirstMessage: firstMessage || \"(no messages)\",\n\t\t\tallMessagesText: allMessages.join(\" \"),\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 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\tprivate persist: boolean;\n\tprivate flushed: boolean = false;\n\tprivate fileEntries: FileEntry[] = [];\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\n\tprivate constructor(cwd: string, sessionDir: string, sessionFile: string | undefined, persist: boolean) {\n\t\tthis.cwd = resolvePath(cwd);\n\t\tthis.sessionDir = normalizePath(sessionDir);\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();\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.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\tthis._buildIndex();\n\t\t\tthis.flushed = true;\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\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.byId.clear();\n\t\tthis.labelsById.clear();\n\t\tthis.leafId = null;\n\t\tthis.flushed = false;\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 _buildIndex(): void {\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.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}\n\n\tprivate _rewriteFile(): void {\n\t\tif (!this.persist || !this.sessionFile) return;\n\t\tconst content = `${this.fileEntries.map((e) => JSON.stringify(e)).join(\"\\n\")}\\n`;\n\t\twriteFileSync(this.sessionFile, content);\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\tgetSessionId(): string {\n\t\treturn this.sessionId;\n\t}\n\n\tgetSessionFile(): string | undefined {\n\t\treturn this.sessionFile;\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\t// Mark as not flushed so when assistant arrives, all entries get written\n\t\t\tthis.flushed = false;\n\t\t\treturn;\n\t\t}\n\n\t\tif (!this.flushed) {\n\t\t\tfor (const e of this.fileEntries) {\n\t\t\t\tappendFileSync(this.sessionFile, `${JSON.stringify(e)}\\n`);\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.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 entry: CompactionEntry<T> = {\n\t\t\ttype: \"compaction\",\n\t\t\tid: generateId(this.byId),\n\t\t\tparentId: this.leafId,\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\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.unshift(current);\n\t\t\tcurrent = current.parentId ? this.byId.get(current.parentId) : undefined;\n\t\t}\n\t\treturn path;\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.fileEntries.filter((e): e is SessionEntry => e.type !== \"session\");\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.sessionFile = newSessionFile;\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\tthis._rewriteFile();\n\t\t\t\tthis.flushed = true;\n\t\t\t} else {\n\t\t\t\tthis.flushed = false;\n\t\t\t}\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 sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions/<encoded-cwd>/).\n\t */\n\tstatic create(cwd: string, sessionDir?: string): SessionManager {\n\t\tconst dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd);\n\t\treturn new SessionManager(cwd, dir, undefined, true);\n\t}\n\n\t/**\n\t * Open a specific session file.\n\t * @param path Path to session file\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, sessionDir?: string, cwdOverride?: string): SessionManager {\n\t\tconst resolvedPath = resolvePath(path);\n\t\t// Extract cwd from session header if possible, otherwise use process.cwd()\n\t\tconst entries = loadEntriesFromFile(resolvedPath);\n\t\tconst header = entries.find((e) => e.type === \"session\") as SessionHeader | undefined;\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);\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 sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions/<encoded-cwd>/).\n\t */\n\tstatic continueRecent(cwd: string, sessionDir?: string): SessionManager {\n\t\tconst dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd);\n\t\tconst mostRecent = findMostRecentSession(dir);\n\t\tif (mostRecent) {\n\t\t\treturn new SessionManager(cwd, dir, mostRecent, true);\n\t\t}\n\t\treturn new SessionManager(cwd, dir, undefined, true);\n\t}\n\n\t/** Create an in-memory session (no file persistence) */\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 sessionDir Optional session directory. If omitted, uses default for targetCwd.\n\t */\n\tstatic forkFrom(sourcePath: string, targetCwd: string, sessionDir?: string): 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);\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\tconst newSessionId = 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\tappendFileSync(newSessionFile, `${JSON.stringify(newHeader)}\\n`);\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);\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 sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions/<encoded-cwd>/).\n\t * @param onProgress Optional callback for progress updates (loaded, total)\n\t */\n\tstatic async list(cwd: string, sessionDir?: string, onProgress?: SessionListProgress): Promise<SessionInfo[]> {\n\t\tconst dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd);\n\t\tconst sessions = await listSessionsFromDir(dir, onProgress);\n\t\tsessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());\n\t\treturn sessions;\n\t}\n\n\t/**\n\t * List all sessions across all project directories.\n\t * @param onProgress Optional callback for progress updates (loaded, total)\n\t */\n\tstatic async listAll(onProgress?: SessionListProgress): Promise<SessionInfo[]> {\n\t\tconst sessionsDir = getSessionsDir();\n\n\t\ttry {\n\t\t\tif (!existsSync(sessionsDir)) {\n\t\t\t\treturn [];\n\t\t\t}\n\t\t\tconst entries = await readdir(sessionsDir, { withFileTypes: true });\n\t\t\tconst dirs = entries.filter((e) => e.isDirectory()).map((e) => join(sessionsDir, 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\tonProgress?.(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/core/session-manager.ts"],"names":[],"mappings":"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","sourcesContent":["import { type AgentMessage, uuidv7 } from \"@earendil-works/pi-agent-core\";\nimport type { ImageContent, Message, TextContent } from \"@earendil-works/pi-ai\";\nimport { randomUUID } from \"crypto\";\nimport {\n\tappendFileSync,\n\tcloseSync,\n\texistsSync,\n\tmkdirSync,\n\topenSync,\n\treaddirSync,\n\treadFileSync,\n\treadSync,\n\tstatSync,\n\twriteFileSync,\n} from \"fs\";\nimport { readdir, readFile, stat } from \"fs/promises\";\nimport { join, resolve } from \"path\";\nimport { getAgentDir as getDefaultAgentDir, getSessionsDir } from \"../config.ts\";\nimport { normalizePath, resolvePath } from \"../utils/paths.ts\";\nimport {\n\ttype BashExecutionMessage,\n\ttype CustomMessage,\n\tcreateBranchSummaryMessage,\n\tcreateCompactionSummaryMessage,\n\tcreateCustomMessage,\n} from \"./messages.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| \"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\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\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, collecting path\n\tconst path: SessionEntry[] = [];\n\tlet current: SessionEntry | undefined = leaf;\n\twhile (current) {\n\t\tpath.unshift(current);\n\t\tcurrent = current.parentId ? byId.get(current.parentId) : undefined;\n\t}\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 getDefaultSessionDirPath(cwd: string, agentDir: string = getDefaultAgentDir()): 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\nexport function getDefaultSessionDir(cwd: string, agentDir: string = getDefaultAgentDir()): string {\n\tconst sessionDir = getDefaultSessionDirPath(cwd, agentDir);\n\tif (!existsSync(sessionDir)) {\n\t\tmkdirSync(sessionDir, { recursive: true });\n\t}\n\treturn sessionDir;\n}\n\n/** Exported for testing */\nexport function loadEntriesFromFile(filePath: string): FileEntry[] {\n\tconst resolvedFilePath = normalizePath(filePath);\n\tif (!existsSync(resolvedFilePath)) return [];\n\n\tconst content = readFileSync(resolvedFilePath, \"utf8\");\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\t// Validate session header\n\tif (entries.length === 0) return entries;\n\tconst header = entries[0];\n\tif (header.type !== \"session\" || typeof (header as any).id !== \"string\") {\n\t\treturn [];\n\t}\n\n\treturn entries;\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(!resolvedCwd || sessionCwdMatches(getSessionHeaderCwd(file.header), resolvedCwd)),\n\t\t\t)\n\t\t\t.map(({ path }) => ({ path, mtime: statSync(path).mtime }))\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 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\nfunction getLastActivityTime(entries: FileEntry[]): number | undefined {\n\tlet lastActivityTime: number | undefined;\n\n\tfor (const entry of entries) {\n\t\tif (entry.type !== \"message\") continue;\n\n\t\tconst message = (entry as SessionMessageEntry).message;\n\t\tif (!isMessageWithContent(message)) continue;\n\t\tif (message.role !== \"user\" && message.role !== \"assistant\") continue;\n\n\t\tconst msgTimestamp = (message as { timestamp?: number }).timestamp;\n\t\tif (typeof msgTimestamp === \"number\") {\n\t\t\tlastActivityTime = Math.max(lastActivityTime ?? 0, msgTimestamp);\n\t\t\tcontinue;\n\t\t}\n\n\t\tconst entryTimestamp = (entry as SessionEntryBase).timestamp;\n\t\tif (typeof entryTimestamp === \"string\") {\n\t\t\tconst t = new Date(entryTimestamp).getTime();\n\t\t\tif (!Number.isNaN(t)) {\n\t\t\t\tlastActivityTime = Math.max(lastActivityTime ?? 0, t);\n\t\t\t}\n\t\t}\n\t}\n\n\treturn lastActivityTime;\n}\n\nfunction getSessionModifiedDate(entries: FileEntry[], header: SessionHeader, statsMtime: Date): Date {\n\tconst lastActivityTime = getLastActivityTime(entries);\n\tif (typeof lastActivityTime === \"number\" && lastActivityTime > 0) {\n\t\treturn new Date(lastActivityTime);\n\t}\n\n\tconst headerTime = typeof header.timestamp === \"string\" ? new Date(header.timestamp).getTime() : NaN;\n\treturn !Number.isNaN(headerTime) ? new Date(headerTime) : statsMtime;\n}\n\nasync function buildSessionInfo(filePath: string): Promise<SessionInfo | null> {\n\ttry {\n\t\tconst content = await readFile(filePath, \"utf8\");\n\t\tconst entries: FileEntry[] = [];\n\t\tconst lines = content.trim().split(\"\\n\");\n\n\t\tfor (const line of lines) {\n\t\t\tif (!line.trim()) continue;\n\t\t\ttry {\n\t\t\t\tentries.push(JSON.parse(line) as FileEntry);\n\t\t\t} catch {\n\t\t\t\t// Skip malformed lines\n\t\t\t}\n\t\t}\n\n\t\tif (entries.length === 0) return null;\n\t\tconst header = entries[0];\n\t\tif (header.type !== \"session\") return null;\n\n\t\tconst stats = await stat(filePath);\n\t\tlet messageCount = 0;\n\t\tlet firstMessage = \"\";\n\t\tconst allMessages: string[] = [];\n\t\tlet name: string | undefined;\n\n\t\tfor (const entry of entries) {\n\t\t\t// Extract session name (use latest, including explicit clears)\n\t\t\tif (entry.type === \"session_info\") {\n\t\t\t\tconst infoEntry = entry as SessionInfoEntry;\n\t\t\t\tname = infoEntry.name?.trim() || undefined;\n\t\t\t}\n\n\t\t\tif (entry.type !== \"message\") continue;\n\t\t\tmessageCount++;\n\n\t\t\tconst message = (entry as SessionMessageEntry).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\tallMessages.push(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\tconst cwd = typeof (header as SessionHeader).cwd === \"string\" ? (header as SessionHeader).cwd : \"\";\n\t\tconst parentSessionPath = (header as SessionHeader).parentSession;\n\n\t\tconst modified = getSessionModifiedDate(entries, header as SessionHeader, stats.mtime);\n\n\t\treturn {\n\t\t\tpath: filePath,\n\t\t\tid: (header as SessionHeader).id,\n\t\t\tcwd,\n\t\t\tname,\n\t\t\tparentSessionPath,\n\t\t\tcreated: new Date((header as SessionHeader).timestamp),\n\t\t\tmodified,\n\t\t\tmessageCount,\n\t\t\tfirstMessage: firstMessage || \"(no messages)\",\n\t\t\tallMessagesText: allMessages.join(\" \"),\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 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\tprivate persist: boolean;\n\tprivate flushed: boolean = false;\n\tprivate fileEntries: FileEntry[] = [];\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\n\tprivate constructor(\n\t\tcwd: string,\n\t\tsessionDir: string,\n\t\tsessionFile: string | undefined,\n\t\tpersist: boolean,\n\t\tnewSessionOptions?: NewSessionOptions,\n\t) {\n\t\tthis.cwd = resolvePath(cwd);\n\t\tthis.sessionDir = normalizePath(sessionDir);\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.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\tthis._buildIndex();\n\t\t\tthis.flushed = true;\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.byId.clear();\n\t\tthis.labelsById.clear();\n\t\tthis.leafId = null;\n\t\tthis.flushed = false;\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 _buildIndex(): void {\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.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}\n\n\tprivate _rewriteFile(): void {\n\t\tif (!this.persist || !this.sessionFile) return;\n\t\tconst content = `${this.fileEntries.map((e) => JSON.stringify(e)).join(\"\\n\")}\\n`;\n\t\twriteFileSync(this.sessionFile, content);\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 this.sessionDir === getDefaultSessionDirPath(this.cwd);\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\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.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 entry: CompactionEntry<T> = {\n\t\t\ttype: \"compaction\",\n\t\t\tid: generateId(this.byId),\n\t\t\tparentId: this.leafId,\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\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.unshift(current);\n\t\t\tcurrent = current.parentId ? this.byId.get(current.parentId) : undefined;\n\t\t}\n\t\treturn path;\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.fileEntries.filter((e): e is SessionEntry => e.type !== \"session\");\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.sessionFile = newSessionFile;\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\tthis._rewriteFile();\n\t\t\t\tthis.flushed = true;\n\t\t\t} else {\n\t\t\t\tthis.flushed = false;\n\t\t\t}\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 sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions/<encoded-cwd>/).\n\t */\n\tstatic create(cwd: string, sessionDir?: string, options?: NewSessionOptions): SessionManager {\n\t\tconst dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd);\n\t\treturn new SessionManager(cwd, dir, undefined, true, options);\n\t}\n\n\t/**\n\t * Open a specific session file.\n\t * @param path Path to session file\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, sessionDir?: string, cwdOverride?: string): SessionManager {\n\t\tconst resolvedPath = resolvePath(path);\n\t\t// Extract cwd from session header if possible, otherwise use process.cwd()\n\t\tconst entries = loadEntriesFromFile(resolvedPath);\n\t\tconst header = entries.find((e) => e.type === \"session\") as SessionHeader | undefined;\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);\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 sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions/<encoded-cwd>/).\n\t */\n\tstatic continueRecent(cwd: string, sessionDir?: string): SessionManager {\n\t\tconst dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd);\n\t\tconst filterCwd = sessionDir !== undefined && dir !== getDefaultSessionDirPath(cwd);\n\t\tconst mostRecent = findMostRecentSession(dir, filterCwd ? cwd : undefined);\n\t\tif (mostRecent) {\n\t\t\treturn new SessionManager(cwd, dir, mostRecent, true);\n\t\t}\n\t\treturn new SessionManager(cwd, dir, undefined, true);\n\t}\n\n\t/** Create an in-memory session (no file persistence) */\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 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\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);\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);\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 sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions/<encoded-cwd>/).\n\t * @param onProgress Optional callback for progress updates (loaded, total)\n\t */\n\tstatic async list(cwd: string, sessionDir?: string, onProgress?: SessionListProgress): Promise<SessionInfo[]> {\n\t\tconst dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd);\n\t\tconst filterCwd = sessionDir !== undefined && dir !== getDefaultSessionDirPath(cwd);\n\t\tconst resolvedCwd = resolvePath(cwd);\n\t\tconst sessions = (await listSessionsFromDir(dir, onProgress)).filter(\n\t\t\t(session) => !filterCwd || sessionCwdMatches(session.cwd, resolvedCwd),\n\t\t);\n\t\tsessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());\n\t\treturn sessions;\n\t}\n\n\t/**\n\t * List all sessions across all project directories.\n\t * @param onProgress Optional callback for progress updates (loaded, total)\n\t */\n\tstatic async listAll(onProgress?: SessionListProgress): Promise<SessionInfo[]>;\n\tstatic async listAll(sessionDir?: string, onProgress?: SessionListProgress): Promise<SessionInfo[]>;\n\tstatic async listAll(\n\t\tsessionDirOrOnProgress?: string | SessionListProgress,\n\t\tonProgress?: SessionListProgress,\n\t): Promise<SessionInfo[]> {\n\t\tconst customSessionDir =\n\t\t\ttypeof sessionDirOrOnProgress === \"string\" ? normalizePath(sessionDirOrOnProgress) : undefined;\n\t\tconst progress = typeof sessionDirOrOnProgress === \"function\" ? sessionDirOrOnProgress : 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 sessionsDir = getSessionsDir();\n\n\t\ttry {\n\t\t\tif (!existsSync(sessionsDir)) {\n\t\t\t\treturn [];\n\t\t\t}\n\t\t\tconst entries = await readdir(sessionsDir, { withFileTypes: true });\n\t\t\tconst dirs = entries.filter((e) => e.isDirectory()).map((e) => join(sessionsDir, 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"]}
@@ -10,6 +10,11 @@ export const CURRENT_SESSION_VERSION = 3;
10
10
  function createSessionId() {
11
11
  return uuidv7();
12
12
  }
13
+ export function assertValidSessionId(id) {
14
+ if (!/^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/.test(id)) {
15
+ throw new Error("Session id must be non-empty, contain only alphanumeric characters, '-', '_', and '.', and start and end with an alphanumeric character");
16
+ }
17
+ }
13
18
  /** Generate a unique short ID (8 hex chars, collision-checked) */
14
19
  function generateId(byId) {
15
20
  for (let i = 0; i < 100; i++) {
@@ -210,11 +215,14 @@ export function buildSessionContext(entries, leafId, byId) {
210
215
  * Compute the default session directory for a cwd.
211
216
  * Encodes cwd into a safe directory name under ~/.pi/agent/sessions/.
212
217
  */
213
- export function getDefaultSessionDir(cwd, agentDir = getDefaultAgentDir()) {
218
+ function getDefaultSessionDirPath(cwd, agentDir = getDefaultAgentDir()) {
214
219
  const resolvedCwd = resolvePath(cwd);
215
220
  const resolvedAgentDir = resolvePath(agentDir);
216
221
  const safePath = `--${resolvedCwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
217
- const sessionDir = join(resolvedAgentDir, "sessions", safePath);
222
+ return join(resolvedAgentDir, "sessions", safePath);
223
+ }
224
+ export function getDefaultSessionDir(cwd, agentDir = getDefaultAgentDir()) {
225
+ const sessionDir = getDefaultSessionDirPath(cwd, agentDir);
218
226
  if (!existsSync(sessionDir)) {
219
227
  mkdirSync(sessionDir, { recursive: true });
220
228
  }
@@ -248,7 +256,7 @@ export function loadEntriesFromFile(filePath) {
248
256
  }
249
257
  return entries;
250
258
  }
251
- function isValidSessionFile(filePath) {
259
+ function readSessionHeader(filePath) {
252
260
  try {
253
261
  const fd = openSync(filePath, "r");
254
262
  const buffer = Buffer.alloc(512);
@@ -256,23 +264,36 @@ function isValidSessionFile(filePath) {
256
264
  closeSync(fd);
257
265
  const firstLine = buffer.toString("utf8", 0, bytesRead).split("\n")[0];
258
266
  if (!firstLine)
259
- return false;
267
+ return null;
260
268
  const header = JSON.parse(firstLine);
261
- return header.type === "session" && typeof header.id === "string";
269
+ if (header.type !== "session" || typeof header.id !== "string") {
270
+ return null;
271
+ }
272
+ return header;
262
273
  }
263
274
  catch {
264
- return false;
275
+ return null;
265
276
  }
266
277
  }
278
+ function getSessionHeaderCwd(header) {
279
+ const cwd = header.cwd;
280
+ return typeof cwd === "string" ? cwd : undefined;
281
+ }
282
+ function sessionCwdMatches(cwd, resolvedCwd) {
283
+ return cwd !== undefined && cwd !== "" && resolvePath(cwd) === resolvedCwd;
284
+ }
267
285
  /** Exported for testing */
268
- export function findMostRecentSession(sessionDir) {
286
+ export function findMostRecentSession(sessionDir, cwd) {
269
287
  const resolvedSessionDir = normalizePath(sessionDir);
288
+ const resolvedCwd = cwd ? resolvePath(cwd) : undefined;
270
289
  try {
271
290
  const files = readdirSync(resolvedSessionDir)
272
291
  .filter((f) => f.endsWith(".jsonl"))
273
292
  .map((f) => join(resolvedSessionDir, f))
274
- .filter(isValidSessionFile)
275
- .map((path) => ({ path, mtime: statSync(path).mtime }))
293
+ .map((path) => ({ path, header: readSessionHeader(path) }))
294
+ .filter((file) => file.header !== null &&
295
+ (!resolvedCwd || sessionCwdMatches(getSessionHeaderCwd(file.header), resolvedCwd)))
296
+ .map(({ path }) => ({ path, mtime: statSync(path).mtime }))
276
297
  .sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
277
298
  return files[0]?.path || null;
278
299
  }
@@ -475,7 +496,7 @@ export class SessionManager {
475
496
  labelsById = new Map();
476
497
  labelTimestampsById = new Map();
477
498
  leafId = null;
478
- constructor(cwd, sessionDir, sessionFile, persist) {
499
+ constructor(cwd, sessionDir, sessionFile, persist, newSessionOptions) {
479
500
  this.cwd = resolvePath(cwd);
480
501
  this.sessionDir = normalizePath(sessionDir);
481
502
  this.persist = persist;
@@ -486,7 +507,7 @@ export class SessionManager {
486
507
  this.setSessionFile(sessionFile);
487
508
  }
488
509
  else {
489
- this.newSession();
510
+ this.newSession(newSessionOptions);
490
511
  }
491
512
  }
492
513
  /** Switch to a different session file (used for resume and branching) */
@@ -519,6 +540,9 @@ export class SessionManager {
519
540
  }
520
541
  }
521
542
  newSession(options) {
543
+ if (options?.id !== undefined) {
544
+ assertValidSessionId(options.id);
545
+ }
522
546
  this.sessionId = options?.id ?? createSessionId();
523
547
  const timestamp = new Date().toISOString();
524
548
  const header = {
@@ -577,6 +601,9 @@ export class SessionManager {
577
601
  getSessionDir() {
578
602
  return this.sessionDir;
579
603
  }
604
+ usesDefaultSessionDir() {
605
+ return this.sessionDir === getDefaultSessionDirPath(this.cwd);
606
+ }
580
607
  getSessionId() {
581
608
  return this.sessionId;
582
609
  }
@@ -588,13 +615,24 @@ export class SessionManager {
588
615
  return;
589
616
  const hasAssistant = this.fileEntries.some((e) => e.type === "message" && e.message.role === "assistant");
590
617
  if (!hasAssistant) {
591
- // Mark as not flushed so when assistant arrives, all entries get written
592
- this.flushed = false;
618
+ if (this.flushed) {
619
+ appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);
620
+ }
621
+ else {
622
+ // Mark as not flushed so when assistant arrives, all entries get written
623
+ this.flushed = false;
624
+ }
593
625
  return;
594
626
  }
595
627
  if (!this.flushed) {
596
- for (const e of this.fileEntries) {
597
- appendFileSync(this.sessionFile, `${JSON.stringify(e)}\n`);
628
+ const fd = openSync(this.sessionFile, "wx");
629
+ try {
630
+ for (const e of this.fileEntries) {
631
+ writeFileSync(fd, `${JSON.stringify(e)}\n`);
632
+ }
633
+ }
634
+ finally {
635
+ closeSync(fd);
598
636
  }
599
637
  this.flushed = true;
600
638
  }
@@ -1004,9 +1042,9 @@ export class SessionManager {
1004
1042
  * @param cwd Working directory (stored in session header)
1005
1043
  * @param sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions/<encoded-cwd>/).
1006
1044
  */
1007
- static create(cwd, sessionDir) {
1045
+ static create(cwd, sessionDir, options) {
1008
1046
  const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd);
1009
- return new SessionManager(cwd, dir, undefined, true);
1047
+ return new SessionManager(cwd, dir, undefined, true, options);
1010
1048
  }
1011
1049
  /**
1012
1050
  * Open a specific session file.
@@ -1031,7 +1069,8 @@ export class SessionManager {
1031
1069
  */
1032
1070
  static continueRecent(cwd, sessionDir) {
1033
1071
  const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd);
1034
- const mostRecent = findMostRecentSession(dir);
1072
+ const filterCwd = sessionDir !== undefined && dir !== getDefaultSessionDirPath(cwd);
1073
+ const mostRecent = findMostRecentSession(dir, filterCwd ? cwd : undefined);
1035
1074
  if (mostRecent) {
1036
1075
  return new SessionManager(cwd, dir, mostRecent, true);
1037
1076
  }
@@ -1048,7 +1087,7 @@ export class SessionManager {
1048
1087
  * @param targetCwd Target working directory (where the new session will be stored)
1049
1088
  * @param sessionDir Optional session directory. If omitted, uses default for targetCwd.
1050
1089
  */
1051
- static forkFrom(sourcePath, targetCwd, sessionDir) {
1090
+ static forkFrom(sourcePath, targetCwd, sessionDir, options) {
1052
1091
  const resolvedSourcePath = resolvePath(sourcePath);
1053
1092
  const resolvedTargetCwd = resolvePath(targetCwd);
1054
1093
  const sourceEntries = loadEntriesFromFile(resolvedSourcePath);
@@ -1064,7 +1103,10 @@ export class SessionManager {
1064
1103
  mkdirSync(dir, { recursive: true });
1065
1104
  }
1066
1105
  // Create new session file with new ID but forked content
1067
- const newSessionId = createSessionId();
1106
+ if (options?.id !== undefined) {
1107
+ assertValidSessionId(options.id);
1108
+ }
1109
+ const newSessionId = options?.id ?? createSessionId();
1068
1110
  const timestamp = new Date().toISOString();
1069
1111
  const fileTimestamp = timestamp.replace(/[:.]/g, "-");
1070
1112
  const newSessionFile = join(dir, `${fileTimestamp}_${newSessionId}.jsonl`);
@@ -1077,7 +1119,7 @@ export class SessionManager {
1077
1119
  cwd: resolvedTargetCwd,
1078
1120
  parentSession: resolvedSourcePath,
1079
1121
  };
1080
- appendFileSync(newSessionFile, `${JSON.stringify(newHeader)}\n`);
1122
+ writeFileSync(newSessionFile, `${JSON.stringify(newHeader)}\n`, { flag: "wx" });
1081
1123
  // Copy all non-header entries from source
1082
1124
  for (const entry of sourceEntries) {
1083
1125
  if (entry.type !== "session") {
@@ -1094,15 +1136,20 @@ export class SessionManager {
1094
1136
  */
1095
1137
  static async list(cwd, sessionDir, onProgress) {
1096
1138
  const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd);
1097
- const sessions = await listSessionsFromDir(dir, onProgress);
1139
+ const filterCwd = sessionDir !== undefined && dir !== getDefaultSessionDirPath(cwd);
1140
+ const resolvedCwd = resolvePath(cwd);
1141
+ const sessions = (await listSessionsFromDir(dir, onProgress)).filter((session) => !filterCwd || sessionCwdMatches(session.cwd, resolvedCwd));
1098
1142
  sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());
1099
1143
  return sessions;
1100
1144
  }
1101
- /**
1102
- * List all sessions across all project directories.
1103
- * @param onProgress Optional callback for progress updates (loaded, total)
1104
- */
1105
- static async listAll(onProgress) {
1145
+ static async listAll(sessionDirOrOnProgress, onProgress) {
1146
+ const customSessionDir = typeof sessionDirOrOnProgress === "string" ? normalizePath(sessionDirOrOnProgress) : undefined;
1147
+ const progress = typeof sessionDirOrOnProgress === "function" ? sessionDirOrOnProgress : onProgress;
1148
+ if (customSessionDir) {
1149
+ const sessions = await listSessionsFromDir(customSessionDir, progress);
1150
+ sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());
1151
+ return sessions;
1152
+ }
1106
1153
  const sessionsDir = getSessionsDir();
1107
1154
  try {
1108
1155
  if (!existsSync(sessionsDir)) {
@@ -1129,7 +1176,7 @@ export class SessionManager {
1129
1176
  const allFiles = dirFiles.flat();
1130
1177
  const results = await buildSessionInfosWithConcurrency(allFiles, () => {
1131
1178
  loaded++;
1132
- onProgress?.(loaded, totalFiles);
1179
+ progress?.(loaded, totalFiles);
1133
1180
  });
1134
1181
  for (const info of results) {
1135
1182
  if (info) {