@mariozechner/pi-coding-agent 0.30.2 → 0.31.1

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 (297) hide show
  1. package/CHANGELOG.md +251 -1
  2. package/README.md +105 -84
  3. package/dist/cli/args.d.ts.map +1 -1
  4. package/dist/cli/args.js +5 -1
  5. package/dist/cli/args.js.map +1 -1
  6. package/dist/cli/file-processor.d.ts +3 -3
  7. package/dist/cli/file-processor.d.ts.map +1 -1
  8. package/dist/cli/file-processor.js +7 -10
  9. package/dist/cli/file-processor.js.map +1 -1
  10. package/dist/config.d.ts +9 -0
  11. package/dist/config.d.ts.map +1 -1
  12. package/dist/config.js +18 -0
  13. package/dist/config.js.map +1 -1
  14. package/dist/core/agent-session.d.ts +73 -34
  15. package/dist/core/agent-session.d.ts.map +1 -1
  16. package/dist/core/agent-session.js +464 -210
  17. package/dist/core/agent-session.js.map +1 -1
  18. package/dist/core/auth-storage.d.ts +2 -2
  19. package/dist/core/auth-storage.d.ts.map +1 -1
  20. package/dist/core/auth-storage.js +2 -2
  21. package/dist/core/auth-storage.js.map +1 -1
  22. package/dist/core/bash-executor.d.ts +2 -2
  23. package/dist/core/bash-executor.d.ts.map +1 -1
  24. package/dist/core/bash-executor.js +2 -2
  25. package/dist/core/bash-executor.js.map +1 -1
  26. package/dist/core/compaction/branch-summarization.d.ts +84 -0
  27. package/dist/core/compaction/branch-summarization.d.ts.map +1 -0
  28. package/dist/core/compaction/branch-summarization.js +233 -0
  29. package/dist/core/compaction/branch-summarization.js.map +1 -0
  30. package/dist/core/{compaction.d.ts → compaction/compaction.d.ts} +38 -19
  31. package/dist/core/compaction/compaction.d.ts.map +1 -0
  32. package/dist/core/compaction/compaction.js +558 -0
  33. package/dist/core/compaction/compaction.js.map +1 -0
  34. package/dist/core/compaction/index.d.ts +7 -0
  35. package/dist/core/compaction/index.d.ts.map +1 -0
  36. package/dist/core/compaction/index.js +7 -0
  37. package/dist/core/compaction/index.js.map +1 -0
  38. package/dist/core/compaction/utils.d.ts +35 -0
  39. package/dist/core/compaction/utils.d.ts.map +1 -0
  40. package/dist/core/compaction/utils.js +138 -0
  41. package/dist/core/compaction/utils.js.map +1 -0
  42. package/dist/core/custom-tools/index.d.ts +2 -1
  43. package/dist/core/custom-tools/index.d.ts.map +1 -1
  44. package/dist/core/custom-tools/index.js +1 -0
  45. package/dist/core/custom-tools/index.js.map +1 -1
  46. package/dist/core/custom-tools/loader.d.ts.map +1 -1
  47. package/dist/core/custom-tools/loader.js +13 -80
  48. package/dist/core/custom-tools/loader.js.map +1 -1
  49. package/dist/core/custom-tools/types.d.ts +84 -59
  50. package/dist/core/custom-tools/types.d.ts.map +1 -1
  51. package/dist/core/custom-tools/types.js.map +1 -1
  52. package/dist/core/custom-tools/wrapper.d.ts +15 -0
  53. package/dist/core/custom-tools/wrapper.d.ts.map +1 -0
  54. package/dist/core/custom-tools/wrapper.js +23 -0
  55. package/dist/core/custom-tools/wrapper.js.map +1 -0
  56. package/dist/core/exec.d.ts +29 -0
  57. package/dist/core/exec.d.ts.map +1 -0
  58. package/dist/core/exec.js +71 -0
  59. package/dist/core/exec.js.map +1 -0
  60. package/dist/core/export-html/index.d.ts +17 -0
  61. package/dist/core/export-html/index.d.ts.map +1 -0
  62. package/dist/core/export-html/index.js +171 -0
  63. package/dist/core/export-html/index.js.map +1 -0
  64. package/dist/core/export-html/template.css +781 -0
  65. package/dist/core/export-html/template.html +54 -0
  66. package/dist/core/export-html/template.js +1185 -0
  67. package/dist/core/export-html/vendor/highlight.min.js +1213 -0
  68. package/dist/core/export-html/vendor/marked.min.js +6 -0
  69. package/dist/core/hooks/index.d.ts +4 -4
  70. package/dist/core/hooks/index.d.ts.map +1 -1
  71. package/dist/core/hooks/index.js +4 -3
  72. package/dist/core/hooks/index.js.map +1 -1
  73. package/dist/core/hooks/loader.d.ts +40 -5
  74. package/dist/core/hooks/loader.d.ts.map +1 -1
  75. package/dist/core/hooks/loader.js +43 -10
  76. package/dist/core/hooks/loader.js.map +1 -1
  77. package/dist/core/hooks/runner.d.ts +94 -18
  78. package/dist/core/hooks/runner.d.ts.map +1 -1
  79. package/dist/core/hooks/runner.js +199 -120
  80. package/dist/core/hooks/runner.js.map +1 -1
  81. package/dist/core/hooks/tool-wrapper.d.ts +1 -1
  82. package/dist/core/hooks/tool-wrapper.d.ts.map +1 -1
  83. package/dist/core/hooks/tool-wrapper.js +36 -19
  84. package/dist/core/hooks/tool-wrapper.js.map +1 -1
  85. package/dist/core/hooks/types.d.ts +407 -96
  86. package/dist/core/hooks/types.d.ts.map +1 -1
  87. package/dist/core/hooks/types.js.map +1 -1
  88. package/dist/core/index.d.ts +4 -3
  89. package/dist/core/index.d.ts.map +1 -1
  90. package/dist/core/index.js.map +1 -1
  91. package/dist/core/messages.d.ts +44 -12
  92. package/dist/core/messages.d.ts.map +1 -1
  93. package/dist/core/messages.js +82 -34
  94. package/dist/core/messages.js.map +1 -1
  95. package/dist/core/model-registry.d.ts +5 -5
  96. package/dist/core/model-registry.d.ts.map +1 -1
  97. package/dist/core/model-registry.js +7 -7
  98. package/dist/core/model-registry.js.map +1 -1
  99. package/dist/core/model-resolver.d.ts +7 -7
  100. package/dist/core/model-resolver.d.ts.map +1 -1
  101. package/dist/core/model-resolver.js +45 -14
  102. package/dist/core/model-resolver.js.map +1 -1
  103. package/dist/core/sdk.d.ts +7 -10
  104. package/dist/core/sdk.d.ts.map +1 -1
  105. package/dist/core/sdk.js +88 -32
  106. package/dist/core/sdk.js.map +1 -1
  107. package/dist/core/session-manager.d.ts +202 -36
  108. package/dist/core/session-manager.d.ts.map +1 -1
  109. package/dist/core/session-manager.js +565 -133
  110. package/dist/core/session-manager.js.map +1 -1
  111. package/dist/core/settings-manager.d.ts +9 -3
  112. package/dist/core/settings-manager.d.ts.map +1 -1
  113. package/dist/core/settings-manager.js +13 -12
  114. package/dist/core/settings-manager.js.map +1 -1
  115. package/dist/core/system-prompt.d.ts.map +1 -1
  116. package/dist/core/system-prompt.js +6 -3
  117. package/dist/core/system-prompt.js.map +1 -1
  118. package/dist/core/tools/bash.d.ts +1 -1
  119. package/dist/core/tools/bash.d.ts.map +1 -1
  120. package/dist/core/tools/bash.js.map +1 -1
  121. package/dist/core/tools/edit-diff.d.ts +33 -0
  122. package/dist/core/tools/edit-diff.d.ts.map +1 -0
  123. package/dist/core/tools/edit-diff.js +171 -0
  124. package/dist/core/tools/edit-diff.js.map +1 -0
  125. package/dist/core/tools/edit.d.ts +7 -1
  126. package/dist/core/tools/edit.d.ts.map +1 -1
  127. package/dist/core/tools/edit.js +20 -95
  128. package/dist/core/tools/edit.js.map +1 -1
  129. package/dist/core/tools/find.d.ts +1 -1
  130. package/dist/core/tools/find.d.ts.map +1 -1
  131. package/dist/core/tools/find.js.map +1 -1
  132. package/dist/core/tools/grep.d.ts +1 -1
  133. package/dist/core/tools/grep.d.ts.map +1 -1
  134. package/dist/core/tools/grep.js.map +1 -1
  135. package/dist/core/tools/index.d.ts +1 -1
  136. package/dist/core/tools/index.d.ts.map +1 -1
  137. package/dist/core/tools/index.js.map +1 -1
  138. package/dist/core/tools/ls.d.ts +1 -1
  139. package/dist/core/tools/ls.d.ts.map +1 -1
  140. package/dist/core/tools/ls.js.map +1 -1
  141. package/dist/core/tools/read.d.ts +1 -1
  142. package/dist/core/tools/read.d.ts.map +1 -1
  143. package/dist/core/tools/read.js.map +1 -1
  144. package/dist/core/tools/write.d.ts +1 -1
  145. package/dist/core/tools/write.d.ts.map +1 -1
  146. package/dist/core/tools/write.js.map +1 -1
  147. package/dist/index.d.ts +8 -7
  148. package/dist/index.d.ts.map +1 -1
  149. package/dist/index.js +5 -3
  150. package/dist/index.js.map +1 -1
  151. package/dist/main.d.ts.map +1 -1
  152. package/dist/main.js +22 -21
  153. package/dist/main.js.map +1 -1
  154. package/dist/modes/interactive/components/assistant-message.d.ts.map +1 -1
  155. package/dist/modes/interactive/components/assistant-message.js +3 -4
  156. package/dist/modes/interactive/components/assistant-message.js.map +1 -1
  157. package/dist/modes/interactive/components/bash-execution.d.ts +1 -1
  158. package/dist/modes/interactive/components/bash-execution.d.ts.map +1 -1
  159. package/dist/modes/interactive/components/bash-execution.js +6 -2
  160. package/dist/modes/interactive/components/bash-execution.js.map +1 -1
  161. package/dist/modes/interactive/components/bordered-loader.d.ts +12 -0
  162. package/dist/modes/interactive/components/bordered-loader.d.ts.map +1 -0
  163. package/dist/modes/interactive/components/bordered-loader.js +30 -0
  164. package/dist/modes/interactive/components/bordered-loader.js.map +1 -0
  165. package/dist/modes/interactive/components/branch-summary-message.d.ts +14 -0
  166. package/dist/modes/interactive/components/branch-summary-message.d.ts.map +1 -0
  167. package/dist/modes/interactive/components/branch-summary-message.js +35 -0
  168. package/dist/modes/interactive/components/branch-summary-message.js.map +1 -0
  169. package/dist/modes/interactive/components/compaction-summary-message.d.ts +14 -0
  170. package/dist/modes/interactive/components/compaction-summary-message.d.ts.map +1 -0
  171. package/dist/modes/interactive/components/compaction-summary-message.js +36 -0
  172. package/dist/modes/interactive/components/compaction-summary-message.js.map +1 -0
  173. package/dist/modes/interactive/components/dynamic-border.d.ts +5 -1
  174. package/dist/modes/interactive/components/dynamic-border.d.ts.map +1 -1
  175. package/dist/modes/interactive/components/dynamic-border.js +5 -1
  176. package/dist/modes/interactive/components/dynamic-border.js.map +1 -1
  177. package/dist/modes/interactive/components/footer.d.ts +12 -6
  178. package/dist/modes/interactive/components/footer.d.ts.map +1 -1
  179. package/dist/modes/interactive/components/footer.js +57 -25
  180. package/dist/modes/interactive/components/footer.js.map +1 -1
  181. package/dist/modes/interactive/components/hook-editor.d.ts +15 -0
  182. package/dist/modes/interactive/components/hook-editor.d.ts.map +1 -0
  183. package/dist/modes/interactive/components/hook-editor.js +95 -0
  184. package/dist/modes/interactive/components/hook-editor.js.map +1 -0
  185. package/dist/modes/interactive/components/hook-message.d.ts +18 -0
  186. package/dist/modes/interactive/components/hook-message.d.ts.map +1 -0
  187. package/dist/modes/interactive/components/hook-message.js +80 -0
  188. package/dist/modes/interactive/components/hook-message.js.map +1 -0
  189. package/dist/modes/interactive/components/model-selector.d.ts +3 -3
  190. package/dist/modes/interactive/components/model-selector.d.ts.map +1 -1
  191. package/dist/modes/interactive/components/model-selector.js +6 -1
  192. package/dist/modes/interactive/components/model-selector.js.map +1 -1
  193. package/dist/modes/interactive/components/tool-execution.d.ts +15 -2
  194. package/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
  195. package/dist/modes/interactive/components/tool-execution.js +70 -21
  196. package/dist/modes/interactive/components/tool-execution.js.map +1 -1
  197. package/dist/modes/interactive/components/tree-selector.d.ts +52 -0
  198. package/dist/modes/interactive/components/tree-selector.d.ts.map +1 -0
  199. package/dist/modes/interactive/components/tree-selector.js +745 -0
  200. package/dist/modes/interactive/components/tree-selector.js.map +1 -0
  201. package/dist/modes/interactive/components/user-message-selector.d.ts +3 -3
  202. package/dist/modes/interactive/components/user-message-selector.d.ts.map +1 -1
  203. package/dist/modes/interactive/components/user-message-selector.js +1 -1
  204. package/dist/modes/interactive/components/user-message-selector.js.map +1 -1
  205. package/dist/modes/interactive/components/user-message.d.ts +1 -1
  206. package/dist/modes/interactive/components/user-message.d.ts.map +1 -1
  207. package/dist/modes/interactive/components/user-message.js +2 -5
  208. package/dist/modes/interactive/components/user-message.js.map +1 -1
  209. package/dist/modes/interactive/interactive-mode.d.ts +29 -12
  210. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  211. package/dist/modes/interactive/interactive-mode.js +589 -208
  212. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  213. package/dist/modes/interactive/theme/dark.json +13 -1
  214. package/dist/modes/interactive/theme/light.json +13 -1
  215. package/dist/modes/interactive/theme/theme-schema.json +34 -0
  216. package/dist/modes/interactive/theme/theme.d.ts +20 -2
  217. package/dist/modes/interactive/theme/theme.d.ts.map +1 -1
  218. package/dist/modes/interactive/theme/theme.js +135 -2
  219. package/dist/modes/interactive/theme/theme.js.map +1 -1
  220. package/dist/modes/print-mode.d.ts +3 -3
  221. package/dist/modes/print-mode.d.ts.map +1 -1
  222. package/dist/modes/print-mode.js +26 -20
  223. package/dist/modes/print-mode.js.map +1 -1
  224. package/dist/modes/rpc/rpc-client.d.ts +13 -10
  225. package/dist/modes/rpc/rpc-client.d.ts.map +1 -1
  226. package/dist/modes/rpc/rpc-client.js +11 -10
  227. package/dist/modes/rpc/rpc-client.js.map +1 -1
  228. package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  229. package/dist/modes/rpc/rpc-mode.js +88 -35
  230. package/dist/modes/rpc/rpc-mode.js.map +1 -1
  231. package/dist/modes/rpc/rpc-types.d.ts +30 -11
  232. package/dist/modes/rpc/rpc-types.d.ts.map +1 -1
  233. package/dist/modes/rpc/rpc-types.js.map +1 -1
  234. package/dist/utils/shell.d.ts +4 -2
  235. package/dist/utils/shell.d.ts.map +1 -1
  236. package/dist/utils/shell.js +36 -7
  237. package/dist/utils/shell.js.map +1 -1
  238. package/dist/utils/tools-manager.d.ts +1 -1
  239. package/dist/utils/tools-manager.d.ts.map +1 -1
  240. package/dist/utils/tools-manager.js +2 -2
  241. package/dist/utils/tools-manager.js.map +1 -1
  242. package/docs/compaction.md +388 -0
  243. package/docs/custom-tools.md +146 -43
  244. package/docs/extension-loading.md +1004 -0
  245. package/docs/hooks.md +562 -596
  246. package/docs/rpc.md +33 -19
  247. package/docs/sdk.md +93 -21
  248. package/docs/session-tree-plan.md +441 -0
  249. package/docs/session.md +172 -21
  250. package/docs/skills.md +2 -0
  251. package/docs/theme.md +31 -2
  252. package/docs/tree.md +197 -0
  253. package/docs/tui.md +343 -0
  254. package/examples/README.md +1 -9
  255. package/examples/custom-tools/hello/index.ts +4 -3
  256. package/examples/custom-tools/question/index.ts +4 -4
  257. package/examples/custom-tools/subagent/index.ts +7 -6
  258. package/examples/custom-tools/todo/index.ts +11 -5
  259. package/examples/hooks/README.md +29 -71
  260. package/examples/hooks/auto-commit-on-exit.ts +8 -9
  261. package/examples/hooks/confirm-destructive.ts +29 -30
  262. package/examples/hooks/custom-compaction.ts +20 -21
  263. package/examples/hooks/dirty-repo-guard.ts +41 -40
  264. package/examples/hooks/file-trigger.ts +10 -5
  265. package/examples/hooks/git-checkpoint.ts +16 -12
  266. package/examples/hooks/handoff.ts +150 -0
  267. package/examples/hooks/permission-gate.ts +1 -1
  268. package/examples/hooks/protected-paths.ts +1 -1
  269. package/examples/hooks/qna.ts +119 -0
  270. package/examples/hooks/snake.ts +343 -0
  271. package/examples/hooks/status-line.ts +40 -0
  272. package/examples/sdk/01-minimal.ts +1 -1
  273. package/examples/sdk/02-custom-model.ts +1 -1
  274. package/examples/sdk/03-custom-prompt.ts +1 -1
  275. package/examples/sdk/04-skills.ts +1 -1
  276. package/examples/sdk/05-tools.ts +4 -4
  277. package/examples/sdk/06-hooks.ts +1 -1
  278. package/examples/sdk/07-context-files.ts +1 -1
  279. package/examples/sdk/08-slash-commands.ts +6 -1
  280. package/examples/sdk/09-api-keys-and-oauth.ts +1 -1
  281. package/examples/sdk/10-settings.ts +1 -1
  282. package/examples/sdk/11-sessions.ts +1 -1
  283. package/examples/sdk/12-full-control.ts +4 -7
  284. package/package.json +6 -6
  285. package/dist/core/compaction.d.ts.map +0 -1
  286. package/dist/core/compaction.js +0 -412
  287. package/dist/core/compaction.js.map +0 -1
  288. package/dist/core/export-html.d.ts +0 -23
  289. package/dist/core/export-html.d.ts.map +0 -1
  290. package/dist/core/export-html.js +0 -1185
  291. package/dist/core/export-html.js.map +0 -1
  292. package/dist/modes/interactive/components/compaction.d.ts +0 -15
  293. package/dist/modes/interactive/components/compaction.d.ts.map +0 -1
  294. package/dist/modes/interactive/components/compaction.js +0 -41
  295. package/dist/modes/interactive/components/compaction.js.map +0 -1
  296. package/docs/hooks-v2.md +0 -385
  297. package/docs/session-tree.md +0 -452
@@ -1,27 +1,63 @@
1
- import { randomBytes } from "crypto";
2
- import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "fs";
1
+ import { randomUUID } from "crypto";
2
+ import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, statSync, writeFileSync, } from "fs";
3
3
  import { join, resolve } from "path";
4
4
  import { getAgentDir as getDefaultAgentDir } from "../config.js";
5
- function uuidv4() {
6
- const bytes = randomBytes(16);
7
- bytes[6] = (bytes[6] & 0x0f) | 0x40;
8
- bytes[8] = (bytes[8] & 0x3f) | 0x80;
9
- const hex = bytes.toString("hex");
10
- return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
5
+ import { createBranchSummaryMessage, createCompactionSummaryMessage, createHookMessage, } from "./messages.js";
6
+ export const CURRENT_SESSION_VERSION = 2;
7
+ /** Generate a unique short ID (8 hex chars, collision-checked) */
8
+ function generateId(byId) {
9
+ for (let i = 0; i < 100; i++) {
10
+ const id = randomUUID().slice(0, 8);
11
+ if (!byId.has(id))
12
+ return id;
13
+ }
14
+ // Fallback to full UUID if somehow we have collisions
15
+ return randomUUID();
11
16
  }
12
- export const SUMMARY_PREFIX = `The conversation history before this point was compacted into the following summary:
13
-
14
- <summary>
15
- `;
16
- export const SUMMARY_SUFFIX = `
17
- </summary>`;
18
- /** Exported for compaction.test.ts */
19
- export function createSummaryMessage(summary) {
20
- return {
21
- role: "user",
22
- content: SUMMARY_PREFIX + summary + SUMMARY_SUFFIX,
23
- timestamp: Date.now(),
24
- };
17
+ /** Migrate v1 v2: add id/parentId tree structure. Mutates in place. */
18
+ function migrateV1ToV2(entries) {
19
+ const ids = new Set();
20
+ let prevId = null;
21
+ for (const entry of entries) {
22
+ if (entry.type === "session") {
23
+ entry.version = 2;
24
+ continue;
25
+ }
26
+ entry.id = generateId(ids);
27
+ entry.parentId = prevId;
28
+ prevId = entry.id;
29
+ // Convert firstKeptEntryIndex to firstKeptEntryId for compaction
30
+ if (entry.type === "compaction") {
31
+ const comp = entry;
32
+ if (typeof comp.firstKeptEntryIndex === "number") {
33
+ const targetEntry = entries[comp.firstKeptEntryIndex];
34
+ if (targetEntry && targetEntry.type !== "session") {
35
+ comp.firstKeptEntryId = targetEntry.id;
36
+ }
37
+ delete comp.firstKeptEntryIndex;
38
+ }
39
+ }
40
+ }
41
+ }
42
+ // Add future migrations here:
43
+ // function migrateV2ToV3(entries: FileEntry[]): void { ... }
44
+ /**
45
+ * Run all necessary migrations to bring entries to current version.
46
+ * Mutates entries in place. Returns true if any migration was applied.
47
+ */
48
+ function migrateToCurrentVersion(entries) {
49
+ const header = entries.find((e) => e.type === "session");
50
+ const version = header?.version ?? 1;
51
+ if (version >= CURRENT_SESSION_VERSION)
52
+ return false;
53
+ if (version < 2)
54
+ migrateV1ToV2(entries);
55
+ // if (version < 3) migrateV2ToV3(entries);
56
+ return true;
57
+ }
58
+ /** Exported for testing */
59
+ export function migrateSessionEntries(entries) {
60
+ migrateToCurrentVersion(entries);
25
61
  }
26
62
  /** Exported for compaction.test.ts */
27
63
  export function parseSessionEntries(content) {
@@ -49,17 +85,46 @@ export function getLatestCompactionEntry(entries) {
49
85
  return null;
50
86
  }
51
87
  /**
52
- * Build the session context from entries. This is what gets sent to the LLM.
53
- *
54
- * If there's a compaction entry, returns the summary message plus messages
55
- * from `firstKeptEntryIndex` onwards. Otherwise returns all messages.
56
- *
57
- * Also extracts the current thinking level and model from the entries.
88
+ * Build the session context from entries using tree traversal.
89
+ * If leafId is provided, walks from that entry to root.
90
+ * Handles compaction and branch summaries along the path.
58
91
  */
59
- export function buildSessionContext(entries) {
92
+ export function buildSessionContext(entries, leafId, byId) {
93
+ // Build uuid index if not available
94
+ if (!byId) {
95
+ byId = new Map();
96
+ for (const entry of entries) {
97
+ byId.set(entry.id, entry);
98
+ }
99
+ }
100
+ // Find leaf
101
+ let leaf;
102
+ if (leafId === null) {
103
+ // Explicitly null - return no messages (navigated to before first entry)
104
+ return { messages: [], thinkingLevel: "off", model: null };
105
+ }
106
+ if (leafId) {
107
+ leaf = byId.get(leafId);
108
+ }
109
+ if (!leaf) {
110
+ // Fallback to last entry (when leafId is undefined)
111
+ leaf = entries[entries.length - 1];
112
+ }
113
+ if (!leaf) {
114
+ return { messages: [], thinkingLevel: "off", model: null };
115
+ }
116
+ // Walk from leaf to root, collecting path
117
+ const path = [];
118
+ let current = leaf;
119
+ while (current) {
120
+ path.unshift(current);
121
+ current = current.parentId ? byId.get(current.parentId) : undefined;
122
+ }
123
+ // Extract settings and find compaction
60
124
  let thinkingLevel = "off";
61
125
  let model = null;
62
- for (const entry of entries) {
126
+ let compaction = null;
127
+ for (const entry of path) {
63
128
  if (entry.type === "thinking_level_change") {
64
129
  thinkingLevel = entry.thinkingLevel;
65
130
  }
@@ -69,34 +134,55 @@ export function buildSessionContext(entries) {
69
134
  else if (entry.type === "message" && entry.message.role === "assistant") {
70
135
  model = { provider: entry.message.provider, modelId: entry.message.model };
71
136
  }
72
- }
73
- let latestCompactionIndex = -1;
74
- for (let i = entries.length - 1; i >= 0; i--) {
75
- if (entries[i].type === "compaction") {
76
- latestCompactionIndex = i;
77
- break;
137
+ else if (entry.type === "compaction") {
138
+ compaction = entry;
78
139
  }
79
140
  }
80
- if (latestCompactionIndex === -1) {
81
- const messages = [];
82
- for (const entry of entries) {
83
- if (entry.type === "message") {
84
- messages.push(entry.message);
141
+ // Build messages and collect corresponding entries
142
+ // When there's a compaction, we need to:
143
+ // 1. Emit summary first (entry = compaction)
144
+ // 2. Emit kept messages (from firstKeptEntryId up to compaction)
145
+ // 3. Emit messages after compaction
146
+ const messages = [];
147
+ const appendMessage = (entry) => {
148
+ if (entry.type === "message") {
149
+ messages.push(entry.message);
150
+ }
151
+ else if (entry.type === "custom_message") {
152
+ messages.push(createHookMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp));
153
+ }
154
+ else if (entry.type === "branch_summary" && entry.summary) {
155
+ messages.push(createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp));
156
+ }
157
+ };
158
+ if (compaction) {
159
+ // Emit summary first
160
+ messages.push(createCompactionSummaryMessage(compaction.summary, compaction.tokensBefore, compaction.timestamp));
161
+ // Find compaction index in path
162
+ const compactionIdx = path.findIndex((e) => e.type === "compaction" && e.id === compaction.id);
163
+ // Emit kept messages (before compaction, starting from firstKeptEntryId)
164
+ let foundFirstKept = false;
165
+ for (let i = 0; i < compactionIdx; i++) {
166
+ const entry = path[i];
167
+ if (entry.id === compaction.firstKeptEntryId) {
168
+ foundFirstKept = true;
85
169
  }
170
+ if (foundFirstKept) {
171
+ appendMessage(entry);
172
+ }
173
+ }
174
+ // Emit messages after compaction
175
+ for (let i = compactionIdx + 1; i < path.length; i++) {
176
+ const entry = path[i];
177
+ appendMessage(entry);
86
178
  }
87
- return { messages, thinkingLevel, model };
88
179
  }
89
- const compactionEvent = entries[latestCompactionIndex];
90
- const keptMessages = [];
91
- for (let i = compactionEvent.firstKeptEntryIndex; i < entries.length; i++) {
92
- const entry = entries[i];
93
- if (entry.type === "message") {
94
- keptMessages.push(entry.message);
180
+ else {
181
+ // No compaction - emit all messages, handle branch summaries and custom messages
182
+ for (const entry of path) {
183
+ appendMessage(entry);
95
184
  }
96
185
  }
97
- const messages = [];
98
- messages.push(createSummaryMessage(compactionEvent.summary));
99
- messages.push(...keptMessages);
100
186
  return { messages, thinkingLevel, model };
101
187
  }
102
188
  /**
@@ -111,7 +197,8 @@ function getDefaultSessionDir(cwd) {
111
197
  }
112
198
  return sessionDir;
113
199
  }
114
- function loadEntriesFromFile(filePath) {
200
+ /** Exported for testing */
201
+ export function loadEntriesFromFile(filePath) {
115
202
  if (!existsSync(filePath))
116
203
  return [];
117
204
  const content = readFileSync(filePath, "utf8");
@@ -128,16 +215,39 @@ function loadEntriesFromFile(filePath) {
128
215
  // Skip malformed lines
129
216
  }
130
217
  }
218
+ // Validate session header
219
+ if (entries.length === 0)
220
+ return entries;
221
+ const header = entries[0];
222
+ if (header.type !== "session" || typeof header.id !== "string") {
223
+ return [];
224
+ }
131
225
  return entries;
132
226
  }
133
- function findMostRecentSession(sessionDir) {
227
+ function isValidSessionFile(filePath) {
228
+ try {
229
+ const fd = openSync(filePath, "r");
230
+ const buffer = Buffer.alloc(512);
231
+ const bytesRead = readSync(fd, buffer, 0, 512, 0);
232
+ closeSync(fd);
233
+ const firstLine = buffer.toString("utf8", 0, bytesRead).split("\n")[0];
234
+ if (!firstLine)
235
+ return false;
236
+ const header = JSON.parse(firstLine);
237
+ return header.type === "session" && typeof header.id === "string";
238
+ }
239
+ catch {
240
+ return false;
241
+ }
242
+ }
243
+ /** Exported for testing */
244
+ export function findMostRecentSession(sessionDir) {
134
245
  try {
135
246
  const files = readdirSync(sessionDir)
136
247
  .filter((f) => f.endsWith(".jsonl"))
137
- .map((f) => ({
138
- path: join(sessionDir, f),
139
- mtime: statSync(join(sessionDir, f)).mtime,
140
- }))
248
+ .map((f) => join(sessionDir, f))
249
+ .filter(isValidSessionFile)
250
+ .map((path) => ({ path, mtime: statSync(path).mtime }))
141
251
  .sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
142
252
  return files[0]?.path || null;
143
253
  }
@@ -145,53 +255,106 @@ function findMostRecentSession(sessionDir) {
145
255
  return null;
146
256
  }
147
257
  }
258
+ /**
259
+ * Manages conversation sessions as append-only trees stored in JSONL files.
260
+ *
261
+ * Each session entry has an id and parentId forming a tree structure. The "leaf"
262
+ * pointer tracks the current position. Appending creates a child of the current leaf.
263
+ * Branching moves the leaf to an earlier entry, allowing new branches without
264
+ * modifying history.
265
+ *
266
+ * Use buildSessionContext() to get the resolved message list for the LLM, which
267
+ * handles compaction summaries and follows the path from root to current leaf.
268
+ */
148
269
  export class SessionManager {
149
270
  sessionId = "";
150
- sessionFile = "";
271
+ sessionFile;
151
272
  sessionDir;
152
273
  cwd;
153
274
  persist;
154
275
  flushed = false;
155
- inMemoryEntries = [];
276
+ fileEntries = [];
277
+ byId = new Map();
278
+ labelsById = new Map();
279
+ leafId = null;
156
280
  constructor(cwd, sessionDir, sessionFile, persist) {
157
281
  this.cwd = cwd;
158
282
  this.sessionDir = sessionDir;
283
+ this.persist = persist;
159
284
  if (persist && sessionDir && !existsSync(sessionDir)) {
160
285
  mkdirSync(sessionDir, { recursive: true });
161
286
  }
162
- this.persist = persist;
163
287
  if (sessionFile) {
164
288
  this.setSessionFile(sessionFile);
165
289
  }
166
290
  else {
167
- this.sessionId = uuidv4();
168
- const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
169
- const sessionFile = join(this.getSessionDir(), `${timestamp}_${this.sessionId}.jsonl`);
170
- this.setSessionFile(sessionFile);
291
+ this.newSession();
171
292
  }
172
293
  }
173
294
  /** Switch to a different session file (used for resume and branching) */
174
295
  setSessionFile(sessionFile) {
175
296
  this.sessionFile = resolve(sessionFile);
176
297
  if (existsSync(this.sessionFile)) {
177
- this.inMemoryEntries = loadEntriesFromFile(this.sessionFile);
178
- const header = this.inMemoryEntries.find((e) => e.type === "session");
179
- this.sessionId = header ? header.id : uuidv4();
298
+ this.fileEntries = loadEntriesFromFile(this.sessionFile);
299
+ const header = this.fileEntries.find((e) => e.type === "session");
300
+ this.sessionId = header?.id ?? randomUUID();
301
+ if (migrateToCurrentVersion(this.fileEntries)) {
302
+ this._rewriteFile();
303
+ }
304
+ this._buildIndex();
180
305
  this.flushed = true;
181
306
  }
182
307
  else {
183
- this.sessionId = uuidv4();
184
- this.inMemoryEntries = [];
185
- this.flushed = false;
186
- const entry = {
187
- type: "session",
188
- id: this.sessionId,
189
- timestamp: new Date().toISOString(),
190
- cwd: this.cwd,
191
- };
192
- this.inMemoryEntries.push(entry);
308
+ this.newSession();
193
309
  }
194
310
  }
311
+ newSession(options) {
312
+ this.sessionId = randomUUID();
313
+ const timestamp = new Date().toISOString();
314
+ const header = {
315
+ type: "session",
316
+ version: CURRENT_SESSION_VERSION,
317
+ id: this.sessionId,
318
+ timestamp,
319
+ cwd: this.cwd,
320
+ parentSession: options?.parentSession,
321
+ };
322
+ this.fileEntries = [header];
323
+ this.byId.clear();
324
+ this.leafId = null;
325
+ this.flushed = false;
326
+ // Only generate filename if persisting and not already set (e.g., via --session flag)
327
+ if (this.persist && !this.sessionFile) {
328
+ const fileTimestamp = timestamp.replace(/[:.]/g, "-");
329
+ this.sessionFile = join(this.getSessionDir(), `${fileTimestamp}_${this.sessionId}.jsonl`);
330
+ }
331
+ return this.sessionFile;
332
+ }
333
+ _buildIndex() {
334
+ this.byId.clear();
335
+ this.labelsById.clear();
336
+ this.leafId = null;
337
+ for (const entry of this.fileEntries) {
338
+ if (entry.type === "session")
339
+ continue;
340
+ this.byId.set(entry.id, entry);
341
+ this.leafId = entry.id;
342
+ if (entry.type === "label") {
343
+ if (entry.label) {
344
+ this.labelsById.set(entry.targetId, entry.label);
345
+ }
346
+ else {
347
+ this.labelsById.delete(entry.targetId);
348
+ }
349
+ }
350
+ }
351
+ }
352
+ _rewriteFile() {
353
+ if (!this.persist || !this.sessionFile)
354
+ return;
355
+ const content = `${this.fileEntries.map((e) => JSON.stringify(e)).join("\n")}\n`;
356
+ writeFileSync(this.sessionFile, content);
357
+ }
195
358
  isPersisted() {
196
359
  return this.persist;
197
360
  }
@@ -207,28 +370,14 @@ export class SessionManager {
207
370
  getSessionFile() {
208
371
  return this.sessionFile;
209
372
  }
210
- reset() {
211
- this.sessionId = uuidv4();
212
- this.flushed = false;
213
- const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
214
- this.sessionFile = join(this.getSessionDir(), `${timestamp}_${this.sessionId}.jsonl`);
215
- this.inMemoryEntries = [
216
- {
217
- type: "session",
218
- id: this.sessionId,
219
- timestamp: new Date().toISOString(),
220
- cwd: this.cwd,
221
- },
222
- ];
223
- }
224
373
  _persist(entry) {
225
- if (!this.persist)
374
+ if (!this.persist || !this.sessionFile)
226
375
  return;
227
- const hasAssistant = this.inMemoryEntries.some((e) => e.type === "message" && e.message.role === "assistant");
376
+ const hasAssistant = this.fileEntries.some((e) => e.type === "message" && e.message.role === "assistant");
228
377
  if (!hasAssistant)
229
378
  return;
230
379
  if (!this.flushed) {
231
- for (const e of this.inMemoryEntries) {
380
+ for (const e of this.fileEntries) {
232
381
  appendFileSync(this.sessionFile, `${JSON.stringify(e)}\n`);
233
382
  }
234
383
  this.flushed = true;
@@ -237,81 +386,364 @@ export class SessionManager {
237
386
  appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);
238
387
  }
239
388
  }
240
- saveMessage(message) {
389
+ _appendEntry(entry) {
390
+ this.fileEntries.push(entry);
391
+ this.byId.set(entry.id, entry);
392
+ this.leafId = entry.id;
393
+ this._persist(entry);
394
+ }
395
+ /** Append a message as child of current leaf, then advance leaf. Returns entry id.
396
+ * Does not allow writing CompactionSummaryMessage and BranchSummaryMessage directly.
397
+ * Reason: we want these to be top-level entries in the session, not message session entries,
398
+ * so it is easier to find them.
399
+ * These need to be appended via appendCompaction() and appendBranchSummary() methods.
400
+ */
401
+ appendMessage(message) {
241
402
  const entry = {
242
403
  type: "message",
404
+ id: generateId(this.byId),
405
+ parentId: this.leafId,
243
406
  timestamp: new Date().toISOString(),
244
407
  message,
245
408
  };
246
- this.inMemoryEntries.push(entry);
247
- this._persist(entry);
409
+ this._appendEntry(entry);
410
+ return entry.id;
248
411
  }
249
- saveThinkingLevelChange(thinkingLevel) {
412
+ /** Append a thinking level change as child of current leaf, then advance leaf. Returns entry id. */
413
+ appendThinkingLevelChange(thinkingLevel) {
250
414
  const entry = {
251
415
  type: "thinking_level_change",
416
+ id: generateId(this.byId),
417
+ parentId: this.leafId,
252
418
  timestamp: new Date().toISOString(),
253
419
  thinkingLevel,
254
420
  };
255
- this.inMemoryEntries.push(entry);
256
- this._persist(entry);
421
+ this._appendEntry(entry);
422
+ return entry.id;
257
423
  }
258
- saveModelChange(provider, modelId) {
424
+ /** Append a model change as child of current leaf, then advance leaf. Returns entry id. */
425
+ appendModelChange(provider, modelId) {
259
426
  const entry = {
260
427
  type: "model_change",
428
+ id: generateId(this.byId),
429
+ parentId: this.leafId,
261
430
  timestamp: new Date().toISOString(),
262
431
  provider,
263
432
  modelId,
264
433
  };
265
- this.inMemoryEntries.push(entry);
266
- this._persist(entry);
434
+ this._appendEntry(entry);
435
+ return entry.id;
267
436
  }
268
- saveCompaction(entry) {
269
- this.inMemoryEntries.push(entry);
270
- this._persist(entry);
437
+ /** Append a compaction summary as child of current leaf, then advance leaf. Returns entry id. */
438
+ appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromHook) {
439
+ const entry = {
440
+ type: "compaction",
441
+ id: generateId(this.byId),
442
+ parentId: this.leafId,
443
+ timestamp: new Date().toISOString(),
444
+ summary,
445
+ firstKeptEntryId,
446
+ tokensBefore,
447
+ details,
448
+ fromHook,
449
+ };
450
+ this._appendEntry(entry);
451
+ return entry.id;
452
+ }
453
+ /** Append a custom entry (for hooks) as child of current leaf, then advance leaf. Returns entry id. */
454
+ appendCustomEntry(customType, data) {
455
+ const entry = {
456
+ type: "custom",
457
+ customType,
458
+ data,
459
+ id: generateId(this.byId),
460
+ parentId: this.leafId,
461
+ timestamp: new Date().toISOString(),
462
+ };
463
+ this._appendEntry(entry);
464
+ return entry.id;
465
+ }
466
+ /**
467
+ * Append a custom message entry (for hooks) that participates in LLM context.
468
+ * @param customType Hook identifier for filtering on reload
469
+ * @param content Message content (string or TextContent/ImageContent array)
470
+ * @param display Whether to show in TUI (true = styled display, false = hidden)
471
+ * @param details Optional hook-specific metadata (not sent to LLM)
472
+ * @returns Entry id
473
+ */
474
+ appendCustomMessageEntry(customType, content, display, details) {
475
+ const entry = {
476
+ type: "custom_message",
477
+ customType,
478
+ content,
479
+ display,
480
+ details,
481
+ id: generateId(this.byId),
482
+ parentId: this.leafId,
483
+ timestamp: new Date().toISOString(),
484
+ };
485
+ this._appendEntry(entry);
486
+ return entry.id;
487
+ }
488
+ // =========================================================================
489
+ // Tree Traversal
490
+ // =========================================================================
491
+ getLeafId() {
492
+ return this.leafId;
493
+ }
494
+ getLeafEntry() {
495
+ return this.leafId ? this.byId.get(this.leafId) : undefined;
496
+ }
497
+ getEntry(id) {
498
+ return this.byId.get(id);
499
+ }
500
+ /**
501
+ * Get all direct children of an entry.
502
+ */
503
+ getChildren(parentId) {
504
+ const children = [];
505
+ for (const entry of this.byId.values()) {
506
+ if (entry.parentId === parentId) {
507
+ children.push(entry);
508
+ }
509
+ }
510
+ return children;
511
+ }
512
+ /**
513
+ * Get the label for an entry, if any.
514
+ */
515
+ getLabel(id) {
516
+ return this.labelsById.get(id);
517
+ }
518
+ /**
519
+ * Set or clear a label on an entry.
520
+ * Labels are user-defined markers for bookmarking/navigation.
521
+ * Pass undefined or empty string to clear the label.
522
+ */
523
+ appendLabelChange(targetId, label) {
524
+ if (!this.byId.has(targetId)) {
525
+ throw new Error(`Entry ${targetId} not found`);
526
+ }
527
+ const entry = {
528
+ type: "label",
529
+ id: generateId(this.byId),
530
+ parentId: this.leafId,
531
+ timestamp: new Date().toISOString(),
532
+ targetId,
533
+ label,
534
+ };
535
+ this._appendEntry(entry);
536
+ if (label) {
537
+ this.labelsById.set(targetId, label);
538
+ }
539
+ else {
540
+ this.labelsById.delete(targetId);
541
+ }
542
+ return entry.id;
543
+ }
544
+ /**
545
+ * Walk from entry to root, returning all entries in path order.
546
+ * Includes all entry types (messages, compaction, model changes, etc.).
547
+ * Use buildSessionContext() to get the resolved messages for the LLM.
548
+ */
549
+ getBranch(fromId) {
550
+ const path = [];
551
+ const startId = fromId ?? this.leafId;
552
+ let current = startId ? this.byId.get(startId) : undefined;
553
+ while (current) {
554
+ path.unshift(current);
555
+ current = current.parentId ? this.byId.get(current.parentId) : undefined;
556
+ }
557
+ return path;
271
558
  }
272
559
  /**
273
560
  * Build the session context (what gets sent to the LLM).
274
- * If compacted, returns summary + kept messages. Otherwise all messages.
275
- * Includes thinking level and model.
561
+ * Uses tree traversal from current leaf.
276
562
  */
277
563
  buildSessionContext() {
278
- return buildSessionContext(this.getEntries());
564
+ return buildSessionContext(this.getEntries(), this.leafId, this.byId);
565
+ }
566
+ /**
567
+ * Get session header.
568
+ */
569
+ getHeader() {
570
+ const h = this.fileEntries.find((e) => e.type === "session");
571
+ return h ? h : null;
279
572
  }
280
573
  /**
281
- * Get all session entries. Returns a defensive copy.
282
- * Use buildSessionContext() if you need the messages for the LLM.
574
+ * Get all session entries (excludes header). Returns a shallow copy.
575
+ * The session is append-only: use appendXXX() to add entries, branch() to
576
+ * change the leaf pointer. Entries cannot be modified or deleted.
283
577
  */
284
578
  getEntries() {
285
- return [...this.inMemoryEntries];
286
- }
287
- createBranchedSessionFromEntries(entries, branchBeforeIndex) {
288
- const newSessionId = uuidv4();
289
- const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
290
- const newSessionFile = join(this.getSessionDir(), `${timestamp}_${newSessionId}.jsonl`);
291
- const newEntries = [];
292
- for (let i = 0; i < branchBeforeIndex; i++) {
293
- const entry = entries[i];
294
- if (entry.type === "session") {
295
- newEntries.push({
296
- ...entry,
297
- id: newSessionId,
298
- timestamp: new Date().toISOString(),
299
- branchedFrom: this.persist ? this.sessionFile : undefined,
300
- });
579
+ return this.fileEntries.filter((e) => e.type !== "session");
580
+ }
581
+ /**
582
+ * Get the session as a tree structure. Returns a shallow defensive copy of all entries.
583
+ * A well-formed session has exactly one root (first entry with parentId === null).
584
+ * Orphaned entries (broken parent chain) are also returned as roots.
585
+ */
586
+ getTree() {
587
+ const entries = this.getEntries();
588
+ const nodeMap = new Map();
589
+ const roots = [];
590
+ // Create nodes with resolved labels
591
+ for (const entry of entries) {
592
+ const label = this.labelsById.get(entry.id);
593
+ nodeMap.set(entry.id, { entry, children: [], label });
594
+ }
595
+ // Build tree
596
+ for (const entry of entries) {
597
+ const node = nodeMap.get(entry.id);
598
+ if (entry.parentId === null || entry.parentId === entry.id) {
599
+ roots.push(node);
301
600
  }
302
601
  else {
303
- newEntries.push(entry);
602
+ const parent = nodeMap.get(entry.parentId);
603
+ if (parent) {
604
+ parent.children.push(node);
605
+ }
606
+ else {
607
+ // Orphan - treat as root
608
+ roots.push(node);
609
+ }
610
+ }
611
+ }
612
+ // Sort children by timestamp (oldest first, newest at bottom)
613
+ // Use iterative approach to avoid stack overflow on deep trees
614
+ const stack = [...roots];
615
+ while (stack.length > 0) {
616
+ const node = stack.pop();
617
+ node.children.sort((a, b) => new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime());
618
+ stack.push(...node.children);
619
+ }
620
+ return roots;
621
+ }
622
+ // =========================================================================
623
+ // Branching
624
+ // =========================================================================
625
+ /**
626
+ * Start a new branch from an earlier entry.
627
+ * Moves the leaf pointer to the specified entry. The next appendXXX() call
628
+ * will create a child of that entry, forming a new branch. Existing entries
629
+ * are not modified or deleted.
630
+ */
631
+ branch(branchFromId) {
632
+ if (!this.byId.has(branchFromId)) {
633
+ throw new Error(`Entry ${branchFromId} not found`);
634
+ }
635
+ this.leafId = branchFromId;
636
+ }
637
+ /**
638
+ * Reset the leaf pointer to null (before any entries).
639
+ * The next appendXXX() call will create a new root entry (parentId = null).
640
+ * Use this when navigating to re-edit the first user message.
641
+ */
642
+ resetLeaf() {
643
+ this.leafId = null;
644
+ }
645
+ /**
646
+ * Start a new branch with a summary of the abandoned path.
647
+ * Same as branch(), but also appends a branch_summary entry that captures
648
+ * context from the abandoned conversation path.
649
+ */
650
+ branchWithSummary(branchFromId, summary, details, fromHook) {
651
+ if (branchFromId !== null && !this.byId.has(branchFromId)) {
652
+ throw new Error(`Entry ${branchFromId} not found`);
653
+ }
654
+ this.leafId = branchFromId;
655
+ const entry = {
656
+ type: "branch_summary",
657
+ id: generateId(this.byId),
658
+ parentId: branchFromId,
659
+ timestamp: new Date().toISOString(),
660
+ fromId: branchFromId ?? "root",
661
+ summary,
662
+ details,
663
+ fromHook,
664
+ };
665
+ this._appendEntry(entry);
666
+ return entry.id;
667
+ }
668
+ /**
669
+ * Create a new session file containing only the path from root to the specified leaf.
670
+ * Useful for extracting a single conversation path from a branched session.
671
+ * Returns the new session file path, or undefined if not persisting.
672
+ */
673
+ createBranchedSession(leafId) {
674
+ const path = this.getBranch(leafId);
675
+ if (path.length === 0) {
676
+ throw new Error(`Entry ${leafId} not found`);
677
+ }
678
+ // Filter out LabelEntry from path - we'll recreate them from the resolved map
679
+ const pathWithoutLabels = path.filter((e) => e.type !== "label");
680
+ const newSessionId = randomUUID();
681
+ const timestamp = new Date().toISOString();
682
+ const fileTimestamp = timestamp.replace(/[:.]/g, "-");
683
+ const newSessionFile = join(this.getSessionDir(), `${fileTimestamp}_${newSessionId}.jsonl`);
684
+ const header = {
685
+ type: "session",
686
+ version: CURRENT_SESSION_VERSION,
687
+ id: newSessionId,
688
+ timestamp,
689
+ cwd: this.cwd,
690
+ parentSession: this.persist ? this.sessionFile : undefined,
691
+ };
692
+ // Collect labels for entries in the path
693
+ const pathEntryIds = new Set(pathWithoutLabels.map((e) => e.id));
694
+ const labelsToWrite = [];
695
+ for (const [targetId, label] of this.labelsById) {
696
+ if (pathEntryIds.has(targetId)) {
697
+ labelsToWrite.push({ targetId, label });
304
698
  }
305
699
  }
306
700
  if (this.persist) {
307
- for (const entry of newEntries) {
701
+ appendFileSync(newSessionFile, `${JSON.stringify(header)}\n`);
702
+ for (const entry of pathWithoutLabels) {
308
703
  appendFileSync(newSessionFile, `${JSON.stringify(entry)}\n`);
309
704
  }
705
+ // Write fresh label entries at the end
706
+ const lastEntryId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null;
707
+ let parentId = lastEntryId;
708
+ const labelEntries = [];
709
+ for (const { targetId, label } of labelsToWrite) {
710
+ const labelEntry = {
711
+ type: "label",
712
+ id: generateId(new Set(pathEntryIds)),
713
+ parentId,
714
+ timestamp: new Date().toISOString(),
715
+ targetId,
716
+ label,
717
+ };
718
+ appendFileSync(newSessionFile, `${JSON.stringify(labelEntry)}\n`);
719
+ pathEntryIds.add(labelEntry.id);
720
+ labelEntries.push(labelEntry);
721
+ parentId = labelEntry.id;
722
+ }
723
+ this.fileEntries = [header, ...pathWithoutLabels, ...labelEntries];
724
+ this.sessionId = newSessionId;
725
+ this._buildIndex();
310
726
  return newSessionFile;
311
727
  }
312
- this.inMemoryEntries = newEntries;
728
+ // In-memory mode: replace current session with the path + labels
729
+ const labelEntries = [];
730
+ let parentId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null;
731
+ for (const { targetId, label } of labelsToWrite) {
732
+ const labelEntry = {
733
+ type: "label",
734
+ id: generateId(new Set([...pathEntryIds, ...labelEntries.map((e) => e.id)])),
735
+ parentId,
736
+ timestamp: new Date().toISOString(),
737
+ targetId,
738
+ label,
739
+ };
740
+ labelEntries.push(labelEntry);
741
+ parentId = labelEntry.id;
742
+ }
743
+ this.fileEntries = [header, ...pathWithoutLabels, ...labelEntries];
313
744
  this.sessionId = newSessionId;
314
- return null;
745
+ this._buildIndex();
746
+ return undefined;
315
747
  }
316
748
  /**
317
749
  * Create a new session.
@@ -320,7 +752,7 @@ export class SessionManager {
320
752
  */
321
753
  static create(cwd, sessionDir) {
322
754
  const dir = sessionDir ?? getDefaultSessionDir(cwd);
323
- return new SessionManager(cwd, dir, null, true);
755
+ return new SessionManager(cwd, dir, undefined, true);
324
756
  }
325
757
  /**
326
758
  * Open a specific session file.
@@ -347,11 +779,11 @@ export class SessionManager {
347
779
  if (mostRecent) {
348
780
  return new SessionManager(cwd, dir, mostRecent, true);
349
781
  }
350
- return new SessionManager(cwd, dir, null, true);
782
+ return new SessionManager(cwd, dir, undefined, true);
351
783
  }
352
784
  /** Create an in-memory session (no file persistence) */
353
785
  static inMemory(cwd = process.cwd()) {
354
- return new SessionManager(cwd, "", null, false);
786
+ return new SessionManager(cwd, "", undefined, false);
355
787
  }
356
788
  /**
357
789
  * List all sessions.