@oh-my-pi/pi-coding-agent 0.1.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 (337) hide show
  1. package/CHANGELOG.md +1629 -0
  2. package/README.md +1041 -0
  3. package/docs/compaction.md +403 -0
  4. package/docs/config-usage.md +113 -0
  5. package/docs/custom-tools.md +541 -0
  6. package/docs/extension-loading.md +1004 -0
  7. package/docs/hooks.md +867 -0
  8. package/docs/rpc.md +1040 -0
  9. package/docs/sdk.md +994 -0
  10. package/docs/session-tree-plan.md +441 -0
  11. package/docs/session.md +240 -0
  12. package/docs/skills.md +290 -0
  13. package/docs/theme.md +670 -0
  14. package/docs/tree.md +197 -0
  15. package/docs/tui.md +341 -0
  16. package/examples/README.md +21 -0
  17. package/examples/custom-tools/README.md +124 -0
  18. package/examples/custom-tools/hello/index.ts +20 -0
  19. package/examples/custom-tools/question/index.ts +84 -0
  20. package/examples/custom-tools/subagent/README.md +172 -0
  21. package/examples/custom-tools/subagent/agents/planner.md +37 -0
  22. package/examples/custom-tools/subagent/agents/scout.md +50 -0
  23. package/examples/custom-tools/subagent/agents/worker.md +24 -0
  24. package/examples/custom-tools/subagent/agents.ts +156 -0
  25. package/examples/custom-tools/subagent/commands/implement-and-review.md +10 -0
  26. package/examples/custom-tools/subagent/commands/implement.md +10 -0
  27. package/examples/custom-tools/subagent/commands/scout-and-plan.md +9 -0
  28. package/examples/custom-tools/subagent/index.ts +1002 -0
  29. package/examples/custom-tools/todo/index.ts +212 -0
  30. package/examples/hooks/README.md +56 -0
  31. package/examples/hooks/auto-commit-on-exit.ts +49 -0
  32. package/examples/hooks/confirm-destructive.ts +59 -0
  33. package/examples/hooks/custom-compaction.ts +116 -0
  34. package/examples/hooks/dirty-repo-guard.ts +52 -0
  35. package/examples/hooks/file-trigger.ts +41 -0
  36. package/examples/hooks/git-checkpoint.ts +53 -0
  37. package/examples/hooks/handoff.ts +150 -0
  38. package/examples/hooks/permission-gate.ts +34 -0
  39. package/examples/hooks/protected-paths.ts +30 -0
  40. package/examples/hooks/qna.ts +119 -0
  41. package/examples/hooks/snake.ts +343 -0
  42. package/examples/hooks/status-line.ts +40 -0
  43. package/examples/sdk/01-minimal.ts +22 -0
  44. package/examples/sdk/02-custom-model.ts +49 -0
  45. package/examples/sdk/03-custom-prompt.ts +44 -0
  46. package/examples/sdk/04-skills.ts +44 -0
  47. package/examples/sdk/05-tools.ts +90 -0
  48. package/examples/sdk/06-hooks.ts +61 -0
  49. package/examples/sdk/07-context-files.ts +36 -0
  50. package/examples/sdk/08-slash-commands.ts +42 -0
  51. package/examples/sdk/09-api-keys-and-oauth.ts +55 -0
  52. package/examples/sdk/10-settings.ts +38 -0
  53. package/examples/sdk/11-sessions.ts +48 -0
  54. package/examples/sdk/12-full-control.ts +95 -0
  55. package/examples/sdk/README.md +154 -0
  56. package/package.json +89 -0
  57. package/src/bun-imports.d.ts +16 -0
  58. package/src/capability/context-file.ts +40 -0
  59. package/src/capability/extension.ts +48 -0
  60. package/src/capability/hook.ts +40 -0
  61. package/src/capability/index.ts +616 -0
  62. package/src/capability/instruction.ts +37 -0
  63. package/src/capability/mcp.ts +52 -0
  64. package/src/capability/prompt.ts +35 -0
  65. package/src/capability/rule.ts +56 -0
  66. package/src/capability/settings.ts +35 -0
  67. package/src/capability/skill.ts +49 -0
  68. package/src/capability/slash-command.ts +40 -0
  69. package/src/capability/system-prompt.ts +35 -0
  70. package/src/capability/tool.ts +38 -0
  71. package/src/capability/types.ts +166 -0
  72. package/src/cli/args.ts +259 -0
  73. package/src/cli/file-processor.ts +121 -0
  74. package/src/cli/list-models.ts +104 -0
  75. package/src/cli/plugin-cli.ts +661 -0
  76. package/src/cli/session-picker.ts +41 -0
  77. package/src/cli/update-cli.ts +274 -0
  78. package/src/cli.ts +10 -0
  79. package/src/config.ts +391 -0
  80. package/src/core/agent-session.ts +2178 -0
  81. package/src/core/auth-storage.ts +258 -0
  82. package/src/core/bash-executor.ts +197 -0
  83. package/src/core/compaction/branch-summarization.ts +315 -0
  84. package/src/core/compaction/compaction.ts +664 -0
  85. package/src/core/compaction/index.ts +7 -0
  86. package/src/core/compaction/utils.ts +153 -0
  87. package/src/core/custom-commands/bundled/review/index.ts +156 -0
  88. package/src/core/custom-commands/index.ts +15 -0
  89. package/src/core/custom-commands/loader.ts +226 -0
  90. package/src/core/custom-commands/types.ts +112 -0
  91. package/src/core/custom-tools/index.ts +22 -0
  92. package/src/core/custom-tools/loader.ts +248 -0
  93. package/src/core/custom-tools/types.ts +185 -0
  94. package/src/core/custom-tools/wrapper.ts +29 -0
  95. package/src/core/exec.ts +139 -0
  96. package/src/core/export-html/index.ts +159 -0
  97. package/src/core/export-html/template.css +774 -0
  98. package/src/core/export-html/template.generated.ts +2 -0
  99. package/src/core/export-html/template.html +45 -0
  100. package/src/core/export-html/template.js +1185 -0
  101. package/src/core/export-html/template.macro.ts +24 -0
  102. package/src/core/file-mentions.ts +54 -0
  103. package/src/core/hooks/index.ts +16 -0
  104. package/src/core/hooks/loader.ts +288 -0
  105. package/src/core/hooks/runner.ts +434 -0
  106. package/src/core/hooks/tool-wrapper.ts +98 -0
  107. package/src/core/hooks/types.ts +770 -0
  108. package/src/core/index.ts +53 -0
  109. package/src/core/logger.ts +112 -0
  110. package/src/core/mcp/client.ts +185 -0
  111. package/src/core/mcp/config.ts +248 -0
  112. package/src/core/mcp/index.ts +45 -0
  113. package/src/core/mcp/loader.ts +99 -0
  114. package/src/core/mcp/manager.ts +235 -0
  115. package/src/core/mcp/tool-bridge.ts +156 -0
  116. package/src/core/mcp/transports/http.ts +316 -0
  117. package/src/core/mcp/transports/index.ts +6 -0
  118. package/src/core/mcp/transports/stdio.ts +252 -0
  119. package/src/core/mcp/types.ts +228 -0
  120. package/src/core/messages.ts +211 -0
  121. package/src/core/model-registry.ts +334 -0
  122. package/src/core/model-resolver.ts +494 -0
  123. package/src/core/plugins/doctor.ts +67 -0
  124. package/src/core/plugins/index.ts +38 -0
  125. package/src/core/plugins/installer.ts +189 -0
  126. package/src/core/plugins/loader.ts +339 -0
  127. package/src/core/plugins/manager.ts +672 -0
  128. package/src/core/plugins/parser.ts +105 -0
  129. package/src/core/plugins/paths.ts +37 -0
  130. package/src/core/plugins/types.ts +190 -0
  131. package/src/core/sdk.ts +900 -0
  132. package/src/core/session-manager.ts +1837 -0
  133. package/src/core/settings-manager.ts +860 -0
  134. package/src/core/skills.ts +352 -0
  135. package/src/core/slash-commands.ts +132 -0
  136. package/src/core/system-prompt.ts +442 -0
  137. package/src/core/timings.ts +25 -0
  138. package/src/core/title-generator.ts +110 -0
  139. package/src/core/tools/ask.ts +193 -0
  140. package/src/core/tools/bash-interceptor.ts +120 -0
  141. package/src/core/tools/bash.ts +91 -0
  142. package/src/core/tools/context.ts +32 -0
  143. package/src/core/tools/edit-diff.ts +487 -0
  144. package/src/core/tools/edit.ts +140 -0
  145. package/src/core/tools/exa/company.ts +59 -0
  146. package/src/core/tools/exa/index.ts +63 -0
  147. package/src/core/tools/exa/linkedin.ts +59 -0
  148. package/src/core/tools/exa/mcp-client.ts +368 -0
  149. package/src/core/tools/exa/render.ts +200 -0
  150. package/src/core/tools/exa/researcher.ts +90 -0
  151. package/src/core/tools/exa/search.ts +338 -0
  152. package/src/core/tools/exa/types.ts +167 -0
  153. package/src/core/tools/exa/websets.ts +248 -0
  154. package/src/core/tools/find.ts +244 -0
  155. package/src/core/tools/grep.ts +584 -0
  156. package/src/core/tools/index.ts +283 -0
  157. package/src/core/tools/ls.ts +142 -0
  158. package/src/core/tools/lsp/client.ts +767 -0
  159. package/src/core/tools/lsp/clients/biome-client.ts +207 -0
  160. package/src/core/tools/lsp/clients/index.ts +49 -0
  161. package/src/core/tools/lsp/clients/lsp-linter-client.ts +98 -0
  162. package/src/core/tools/lsp/config.ts +845 -0
  163. package/src/core/tools/lsp/edits.ts +110 -0
  164. package/src/core/tools/lsp/index.ts +1364 -0
  165. package/src/core/tools/lsp/render.ts +560 -0
  166. package/src/core/tools/lsp/rust-analyzer.ts +145 -0
  167. package/src/core/tools/lsp/types.ts +495 -0
  168. package/src/core/tools/lsp/utils.ts +526 -0
  169. package/src/core/tools/notebook.ts +182 -0
  170. package/src/core/tools/output.ts +198 -0
  171. package/src/core/tools/path-utils.ts +61 -0
  172. package/src/core/tools/read.ts +507 -0
  173. package/src/core/tools/renderers.ts +820 -0
  174. package/src/core/tools/review.ts +275 -0
  175. package/src/core/tools/rulebook.ts +124 -0
  176. package/src/core/tools/task/agents.ts +158 -0
  177. package/src/core/tools/task/artifacts.ts +114 -0
  178. package/src/core/tools/task/commands.ts +157 -0
  179. package/src/core/tools/task/discovery.ts +217 -0
  180. package/src/core/tools/task/executor.ts +531 -0
  181. package/src/core/tools/task/index.ts +548 -0
  182. package/src/core/tools/task/model-resolver.ts +176 -0
  183. package/src/core/tools/task/parallel.ts +38 -0
  184. package/src/core/tools/task/render.ts +502 -0
  185. package/src/core/tools/task/subprocess-tool-registry.ts +89 -0
  186. package/src/core/tools/task/types.ts +142 -0
  187. package/src/core/tools/truncate.ts +265 -0
  188. package/src/core/tools/web-fetch.ts +2511 -0
  189. package/src/core/tools/web-search/auth.ts +199 -0
  190. package/src/core/tools/web-search/index.ts +583 -0
  191. package/src/core/tools/web-search/providers/anthropic.ts +198 -0
  192. package/src/core/tools/web-search/providers/exa.ts +196 -0
  193. package/src/core/tools/web-search/providers/perplexity.ts +195 -0
  194. package/src/core/tools/web-search/render.ts +372 -0
  195. package/src/core/tools/web-search/types.ts +180 -0
  196. package/src/core/tools/write.ts +63 -0
  197. package/src/core/ttsr.ts +211 -0
  198. package/src/core/utils.ts +187 -0
  199. package/src/discovery/agents-md.ts +75 -0
  200. package/src/discovery/builtin.ts +647 -0
  201. package/src/discovery/claude.ts +623 -0
  202. package/src/discovery/cline.ts +104 -0
  203. package/src/discovery/codex.ts +571 -0
  204. package/src/discovery/cursor.ts +266 -0
  205. package/src/discovery/gemini.ts +368 -0
  206. package/src/discovery/github.ts +120 -0
  207. package/src/discovery/helpers.test.ts +127 -0
  208. package/src/discovery/helpers.ts +249 -0
  209. package/src/discovery/index.ts +84 -0
  210. package/src/discovery/mcp-json.ts +127 -0
  211. package/src/discovery/vscode.ts +99 -0
  212. package/src/discovery/windsurf.ts +219 -0
  213. package/src/index.ts +192 -0
  214. package/src/main.ts +507 -0
  215. package/src/migrations.ts +156 -0
  216. package/src/modes/cleanup.ts +23 -0
  217. package/src/modes/index.ts +48 -0
  218. package/src/modes/interactive/components/armin.ts +382 -0
  219. package/src/modes/interactive/components/assistant-message.ts +86 -0
  220. package/src/modes/interactive/components/bash-execution.ts +199 -0
  221. package/src/modes/interactive/components/bordered-loader.ts +41 -0
  222. package/src/modes/interactive/components/branch-summary-message.ts +42 -0
  223. package/src/modes/interactive/components/compaction-summary-message.ts +45 -0
  224. package/src/modes/interactive/components/custom-editor.ts +122 -0
  225. package/src/modes/interactive/components/diff.ts +147 -0
  226. package/src/modes/interactive/components/dynamic-border.ts +25 -0
  227. package/src/modes/interactive/components/extensions/extension-dashboard.ts +296 -0
  228. package/src/modes/interactive/components/extensions/extension-list.ts +479 -0
  229. package/src/modes/interactive/components/extensions/index.ts +9 -0
  230. package/src/modes/interactive/components/extensions/inspector-panel.ts +313 -0
  231. package/src/modes/interactive/components/extensions/state-manager.ts +558 -0
  232. package/src/modes/interactive/components/extensions/types.ts +191 -0
  233. package/src/modes/interactive/components/hook-editor.ts +117 -0
  234. package/src/modes/interactive/components/hook-input.ts +64 -0
  235. package/src/modes/interactive/components/hook-message.ts +96 -0
  236. package/src/modes/interactive/components/hook-selector.ts +91 -0
  237. package/src/modes/interactive/components/model-selector.ts +560 -0
  238. package/src/modes/interactive/components/oauth-selector.ts +136 -0
  239. package/src/modes/interactive/components/plugin-settings.ts +481 -0
  240. package/src/modes/interactive/components/queue-mode-selector.ts +56 -0
  241. package/src/modes/interactive/components/session-selector.ts +220 -0
  242. package/src/modes/interactive/components/settings-defs.ts +597 -0
  243. package/src/modes/interactive/components/settings-selector.ts +545 -0
  244. package/src/modes/interactive/components/show-images-selector.ts +45 -0
  245. package/src/modes/interactive/components/status-line/index.ts +4 -0
  246. package/src/modes/interactive/components/status-line/presets.ts +94 -0
  247. package/src/modes/interactive/components/status-line/segments.ts +350 -0
  248. package/src/modes/interactive/components/status-line/separators.ts +55 -0
  249. package/src/modes/interactive/components/status-line/types.ts +81 -0
  250. package/src/modes/interactive/components/status-line-segment-editor.ts +357 -0
  251. package/src/modes/interactive/components/status-line.ts +384 -0
  252. package/src/modes/interactive/components/theme-selector.ts +62 -0
  253. package/src/modes/interactive/components/thinking-selector.ts +64 -0
  254. package/src/modes/interactive/components/tool-execution.ts +946 -0
  255. package/src/modes/interactive/components/tree-selector.ts +877 -0
  256. package/src/modes/interactive/components/ttsr-notification.ts +82 -0
  257. package/src/modes/interactive/components/user-message-selector.ts +159 -0
  258. package/src/modes/interactive/components/user-message.ts +18 -0
  259. package/src/modes/interactive/components/visual-truncate.ts +50 -0
  260. package/src/modes/interactive/components/welcome.ts +228 -0
  261. package/src/modes/interactive/interactive-mode.ts +2669 -0
  262. package/src/modes/interactive/theme/dark.json +102 -0
  263. package/src/modes/interactive/theme/defaults/dark-arctic.json +111 -0
  264. package/src/modes/interactive/theme/defaults/dark-catppuccin.json +106 -0
  265. package/src/modes/interactive/theme/defaults/dark-cyberpunk.json +109 -0
  266. package/src/modes/interactive/theme/defaults/dark-dracula.json +105 -0
  267. package/src/modes/interactive/theme/defaults/dark-forest.json +103 -0
  268. package/src/modes/interactive/theme/defaults/dark-github.json +112 -0
  269. package/src/modes/interactive/theme/defaults/dark-gruvbox.json +119 -0
  270. package/src/modes/interactive/theme/defaults/dark-monochrome.json +101 -0
  271. package/src/modes/interactive/theme/defaults/dark-monokai.json +105 -0
  272. package/src/modes/interactive/theme/defaults/dark-nord.json +104 -0
  273. package/src/modes/interactive/theme/defaults/dark-ocean.json +108 -0
  274. package/src/modes/interactive/theme/defaults/dark-one.json +107 -0
  275. package/src/modes/interactive/theme/defaults/dark-retro.json +99 -0
  276. package/src/modes/interactive/theme/defaults/dark-rose-pine.json +95 -0
  277. package/src/modes/interactive/theme/defaults/dark-solarized.json +96 -0
  278. package/src/modes/interactive/theme/defaults/dark-sunset.json +106 -0
  279. package/src/modes/interactive/theme/defaults/dark-synthwave.json +102 -0
  280. package/src/modes/interactive/theme/defaults/dark-tokyo-night.json +108 -0
  281. package/src/modes/interactive/theme/defaults/index.ts +67 -0
  282. package/src/modes/interactive/theme/defaults/light-arctic.json +106 -0
  283. package/src/modes/interactive/theme/defaults/light-catppuccin.json +105 -0
  284. package/src/modes/interactive/theme/defaults/light-cyberpunk.json +103 -0
  285. package/src/modes/interactive/theme/defaults/light-forest.json +107 -0
  286. package/src/modes/interactive/theme/defaults/light-github.json +114 -0
  287. package/src/modes/interactive/theme/defaults/light-gruvbox.json +115 -0
  288. package/src/modes/interactive/theme/defaults/light-monochrome.json +100 -0
  289. package/src/modes/interactive/theme/defaults/light-ocean.json +106 -0
  290. package/src/modes/interactive/theme/defaults/light-one.json +105 -0
  291. package/src/modes/interactive/theme/defaults/light-retro.json +105 -0
  292. package/src/modes/interactive/theme/defaults/light-solarized.json +101 -0
  293. package/src/modes/interactive/theme/defaults/light-sunset.json +106 -0
  294. package/src/modes/interactive/theme/defaults/light-synthwave.json +105 -0
  295. package/src/modes/interactive/theme/defaults/light-tokyo-night.json +118 -0
  296. package/src/modes/interactive/theme/light.json +99 -0
  297. package/src/modes/interactive/theme/theme-schema.json +424 -0
  298. package/src/modes/interactive/theme/theme.ts +2211 -0
  299. package/src/modes/print-mode.ts +163 -0
  300. package/src/modes/rpc/rpc-client.ts +527 -0
  301. package/src/modes/rpc/rpc-mode.ts +494 -0
  302. package/src/modes/rpc/rpc-types.ts +203 -0
  303. package/src/prompts/architect-plan.md +10 -0
  304. package/src/prompts/branch-summary-preamble.md +3 -0
  305. package/src/prompts/branch-summary.md +28 -0
  306. package/src/prompts/browser.md +71 -0
  307. package/src/prompts/compaction-summary.md +34 -0
  308. package/src/prompts/compaction-turn-prefix.md +16 -0
  309. package/src/prompts/compaction-update-summary.md +41 -0
  310. package/src/prompts/explore.md +82 -0
  311. package/src/prompts/implement-with-critic.md +11 -0
  312. package/src/prompts/implement.md +11 -0
  313. package/src/prompts/init.md +30 -0
  314. package/src/prompts/plan.md +54 -0
  315. package/src/prompts/reviewer.md +81 -0
  316. package/src/prompts/summarization-system.md +3 -0
  317. package/src/prompts/system-prompt.md +27 -0
  318. package/src/prompts/task.md +56 -0
  319. package/src/prompts/title-system.md +8 -0
  320. package/src/prompts/tools/ask.md +24 -0
  321. package/src/prompts/tools/bash.md +23 -0
  322. package/src/prompts/tools/edit.md +9 -0
  323. package/src/prompts/tools/find.md +6 -0
  324. package/src/prompts/tools/grep.md +12 -0
  325. package/src/prompts/tools/lsp.md +14 -0
  326. package/src/prompts/tools/output.md +23 -0
  327. package/src/prompts/tools/read.md +25 -0
  328. package/src/prompts/tools/web-fetch.md +8 -0
  329. package/src/prompts/tools/web-search.md +10 -0
  330. package/src/prompts/tools/write.md +10 -0
  331. package/src/utils/changelog.ts +99 -0
  332. package/src/utils/clipboard.ts +265 -0
  333. package/src/utils/fuzzy.ts +108 -0
  334. package/src/utils/mime.ts +30 -0
  335. package/src/utils/shell-snapshot.ts +218 -0
  336. package/src/utils/shell.ts +364 -0
  337. package/src/utils/tools-manager.ts +265 -0
@@ -0,0 +1,1837 @@
1
+ import {
2
+ appendFileSync,
3
+ closeSync,
4
+ createWriteStream,
5
+ existsSync,
6
+ fsyncSync,
7
+ mkdirSync,
8
+ openSync,
9
+ readdirSync,
10
+ readFileSync,
11
+ readSync,
12
+ renameSync,
13
+ statSync,
14
+ unlinkSync,
15
+ type WriteStream,
16
+ writeFileSync,
17
+ } from "node:fs";
18
+ import { basename, join, resolve } from "node:path";
19
+ import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
20
+ import type { ImageContent, Message, TextContent, Usage } from "@oh-my-pi/pi-ai";
21
+ import { nanoid } from "nanoid";
22
+ import sharp from "sharp";
23
+ import { getAgentDir as getDefaultAgentDir } from "../config";
24
+ import {
25
+ type BashExecutionMessage,
26
+ createBranchSummaryMessage,
27
+ createCompactionSummaryMessage,
28
+ createHookMessage,
29
+ type HookMessage,
30
+ } from "./messages";
31
+
32
+ export const CURRENT_SESSION_VERSION = 2;
33
+
34
+ export interface SessionHeader {
35
+ type: "session";
36
+ version?: number; // v1 sessions don't have this
37
+ id: string;
38
+ title?: string; // Auto-generated title from first message
39
+ timestamp: string;
40
+ cwd: string;
41
+ parentSession?: string;
42
+ }
43
+
44
+ export interface NewSessionOptions {
45
+ parentSession?: string;
46
+ }
47
+
48
+ export interface SessionEntryBase {
49
+ type: string;
50
+ id: string;
51
+ parentId: string | null;
52
+ timestamp: string;
53
+ }
54
+
55
+ export interface SessionMessageEntry extends SessionEntryBase {
56
+ type: "message";
57
+ message: AgentMessage;
58
+ }
59
+
60
+ export interface ThinkingLevelChangeEntry extends SessionEntryBase {
61
+ type: "thinking_level_change";
62
+ thinkingLevel: string;
63
+ }
64
+
65
+ export interface ModelChangeEntry extends SessionEntryBase {
66
+ type: "model_change";
67
+ /** Model in "provider/modelId" format */
68
+ model: string;
69
+ /** Role: "default", "smol", "slow", etc. Undefined treated as "default" */
70
+ role?: string;
71
+ }
72
+
73
+ export interface CompactionEntry<T = unknown> extends SessionEntryBase {
74
+ type: "compaction";
75
+ summary: string;
76
+ firstKeptEntryId: string;
77
+ tokensBefore: number;
78
+ /** Hook-specific data (e.g., ArtifactIndex, version markers for structured compaction) */
79
+ details?: T;
80
+ /** True if generated by a hook, undefined/false if pi-generated (backward compatible) */
81
+ fromHook?: boolean;
82
+ }
83
+
84
+ export interface BranchSummaryEntry<T = unknown> extends SessionEntryBase {
85
+ type: "branch_summary";
86
+ fromId: string;
87
+ summary: string;
88
+ /** Hook-specific data (not sent to LLM) */
89
+ details?: T;
90
+ /** True if generated by a hook, false if pi-generated */
91
+ fromHook?: boolean;
92
+ }
93
+
94
+ /**
95
+ * Custom entry for hooks to store hook-specific data in the session.
96
+ * Use customType to identify your hook's entries.
97
+ *
98
+ * Purpose: Persist hook state across session reloads. On reload, hooks can
99
+ * scan entries for their customType and reconstruct internal state.
100
+ *
101
+ * Does NOT participate in LLM context (ignored by buildSessionContext).
102
+ * For injecting content into context, see CustomMessageEntry.
103
+ */
104
+ export interface CustomEntry<T = unknown> extends SessionEntryBase {
105
+ type: "custom";
106
+ customType: string;
107
+ data?: T;
108
+ }
109
+
110
+ /** Label entry for user-defined bookmarks/markers on entries. */
111
+ export interface LabelEntry extends SessionEntryBase {
112
+ type: "label";
113
+ targetId: string;
114
+ label: string | undefined;
115
+ }
116
+
117
+ /** TTSR injection entry - tracks which time-traveling rules have been injected this session. */
118
+ export interface TtsrInjectionEntry extends SessionEntryBase {
119
+ type: "ttsr_injection";
120
+ /** Names of rules that were injected */
121
+ injectedRules: string[];
122
+ }
123
+
124
+ /**
125
+ * Custom message entry for hooks to inject messages into LLM context.
126
+ * Use customType to identify your hook's entries.
127
+ *
128
+ * Unlike CustomEntry, this DOES participate in LLM context.
129
+ * The content is converted to a user message in buildSessionContext().
130
+ * Use details for hook-specific metadata (not sent to LLM).
131
+ *
132
+ * display controls TUI rendering:
133
+ * - false: hidden entirely
134
+ * - true: rendered with distinct styling (different from user messages)
135
+ */
136
+ export interface CustomMessageEntry<T = unknown> extends SessionEntryBase {
137
+ type: "custom_message";
138
+ customType: string;
139
+ content: string | (TextContent | ImageContent)[];
140
+ details?: T;
141
+ display: boolean;
142
+ }
143
+
144
+ /** Session entry - has id/parentId for tree structure (returned by "read" methods in SessionManager) */
145
+ export type SessionEntry =
146
+ | SessionMessageEntry
147
+ | ThinkingLevelChangeEntry
148
+ | ModelChangeEntry
149
+ | CompactionEntry
150
+ | BranchSummaryEntry
151
+ | CustomEntry
152
+ | CustomMessageEntry
153
+ | LabelEntry
154
+ | TtsrInjectionEntry;
155
+
156
+ /** Raw file entry (includes header) */
157
+ export type FileEntry = SessionHeader | SessionEntry;
158
+
159
+ /** Tree node for getTree() - defensive copy of session structure */
160
+ export interface SessionTreeNode {
161
+ entry: SessionEntry;
162
+ children: SessionTreeNode[];
163
+ /** Resolved label for this entry, if any */
164
+ label?: string;
165
+ }
166
+
167
+ export interface SessionContext {
168
+ messages: AgentMessage[];
169
+ thinkingLevel: string;
170
+ /** Model roles: { default: "provider/modelId", small: "provider/modelId", ... } */
171
+ models: Record<string, string>;
172
+ /** Names of TTSR rules that have been injected this session */
173
+ injectedTtsrRules: string[];
174
+ }
175
+
176
+ export interface SessionInfo {
177
+ path: string;
178
+ id: string;
179
+ title?: string;
180
+ created: Date;
181
+ modified: Date;
182
+ messageCount: number;
183
+ firstMessage: string;
184
+ allMessagesText: string;
185
+ }
186
+
187
+ export type ReadonlySessionManager = Pick<
188
+ SessionManager,
189
+ | "getCwd"
190
+ | "getSessionDir"
191
+ | "getSessionId"
192
+ | "getSessionFile"
193
+ | "getLeafId"
194
+ | "getLeafEntry"
195
+ | "getEntry"
196
+ | "getLabel"
197
+ | "getBranch"
198
+ | "getHeader"
199
+ | "getEntries"
200
+ | "getTree"
201
+ | "getUsageStatistics"
202
+ >;
203
+
204
+ /** Generate a unique short ID (8 hex chars, collision-checked) */
205
+ function generateId(byId: { has(id: string): boolean }): string {
206
+ for (let i = 0; i < 100; i++) {
207
+ const id = nanoid(8);
208
+ if (!byId.has(id)) return id;
209
+ }
210
+ return nanoid(); // fallback to full nanoid
211
+ }
212
+
213
+ /** Migrate v1 → v2: add id/parentId tree structure. Mutates in place. */
214
+ function migrateV1ToV2(entries: FileEntry[]): void {
215
+ const ids = new Set<string>();
216
+ let prevId: string | null = null;
217
+
218
+ for (const entry of entries) {
219
+ if (entry.type === "session") {
220
+ entry.version = 2;
221
+ continue;
222
+ }
223
+
224
+ entry.id = generateId(ids);
225
+ entry.parentId = prevId;
226
+ prevId = entry.id;
227
+
228
+ // Convert firstKeptEntryIndex to firstKeptEntryId for compaction
229
+ if (entry.type === "compaction") {
230
+ const comp = entry as CompactionEntry & { firstKeptEntryIndex?: number };
231
+ if (typeof comp.firstKeptEntryIndex === "number") {
232
+ const targetEntry = entries[comp.firstKeptEntryIndex];
233
+ if (targetEntry && targetEntry.type !== "session") {
234
+ comp.firstKeptEntryId = targetEntry.id;
235
+ }
236
+ delete comp.firstKeptEntryIndex;
237
+ }
238
+ }
239
+ }
240
+ }
241
+
242
+ // Add future migrations here:
243
+ // function migrateV2ToV3(entries: FileEntry[]): void { ... }
244
+
245
+ /**
246
+ * Run all necessary migrations to bring entries to current version.
247
+ * Mutates entries in place. Returns true if any migration was applied.
248
+ */
249
+ function migrateToCurrentVersion(entries: FileEntry[]): boolean {
250
+ const header = entries.find((e) => e.type === "session") as SessionHeader | undefined;
251
+ const version = header?.version ?? 1;
252
+
253
+ if (version >= CURRENT_SESSION_VERSION) return false;
254
+
255
+ if (version < 2) migrateV1ToV2(entries);
256
+ // if (version < 3) migrateV2ToV3(entries);
257
+
258
+ return true;
259
+ }
260
+
261
+ /** Exported for testing */
262
+ export function migrateSessionEntries(entries: FileEntry[]): void {
263
+ migrateToCurrentVersion(entries);
264
+ }
265
+
266
+ /** Exported for compaction.test.ts */
267
+ export function parseSessionEntries(content: string): FileEntry[] {
268
+ const entries: FileEntry[] = [];
269
+ const lines = content.trim().split("\n");
270
+
271
+ for (const line of lines) {
272
+ if (!line.trim()) continue;
273
+ try {
274
+ const entry = JSON.parse(line) as FileEntry;
275
+ entries.push(entry);
276
+ } catch {
277
+ // Skip malformed lines
278
+ }
279
+ }
280
+
281
+ return entries;
282
+ }
283
+
284
+ export function getLatestCompactionEntry(entries: SessionEntry[]): CompactionEntry | null {
285
+ for (let i = entries.length - 1; i >= 0; i--) {
286
+ if (entries[i].type === "compaction") {
287
+ return entries[i] as CompactionEntry;
288
+ }
289
+ }
290
+ return null;
291
+ }
292
+
293
+ function toError(value: unknown): Error {
294
+ return value instanceof Error ? value : new Error(String(value));
295
+ }
296
+
297
+ /**
298
+ * Build the session context from entries using tree traversal.
299
+ * If leafId is provided, walks from that entry to root.
300
+ * Handles compaction and branch summaries along the path.
301
+ */
302
+ export function buildSessionContext(
303
+ entries: SessionEntry[],
304
+ leafId?: string | null,
305
+ byId?: Map<string, SessionEntry>,
306
+ ): SessionContext {
307
+ // Build uuid index if not available
308
+ if (!byId) {
309
+ byId = new Map<string, SessionEntry>();
310
+ for (const entry of entries) {
311
+ byId.set(entry.id, entry);
312
+ }
313
+ }
314
+
315
+ // Find leaf
316
+ let leaf: SessionEntry | undefined;
317
+ if (leafId === null) {
318
+ // Explicitly null - return no messages (navigated to before first entry)
319
+ return { messages: [], thinkingLevel: "off", models: {}, injectedTtsrRules: [] };
320
+ }
321
+ if (leafId) {
322
+ leaf = byId.get(leafId);
323
+ }
324
+ if (!leaf) {
325
+ // Fallback to last entry (when leafId is undefined)
326
+ leaf = entries[entries.length - 1];
327
+ }
328
+
329
+ if (!leaf) {
330
+ return { messages: [], thinkingLevel: "off", models: {}, injectedTtsrRules: [] };
331
+ }
332
+
333
+ // Walk from leaf to root, collecting path
334
+ const path: SessionEntry[] = [];
335
+ let current: SessionEntry | undefined = leaf;
336
+ while (current) {
337
+ path.unshift(current);
338
+ current = current.parentId ? byId.get(current.parentId) : undefined;
339
+ }
340
+
341
+ // Extract settings and find compaction
342
+ let thinkingLevel = "off";
343
+ const models: Record<string, string> = {};
344
+ let compaction: CompactionEntry | null = null;
345
+ const injectedTtsrRulesSet = new Set<string>();
346
+
347
+ for (const entry of path) {
348
+ if (entry.type === "thinking_level_change") {
349
+ thinkingLevel = entry.thinkingLevel;
350
+ } else if (entry.type === "model_change") {
351
+ // New format: { model: "provider/id", role?: string }
352
+ if (entry.model) {
353
+ const role = entry.role ?? "default";
354
+ models[role] = entry.model;
355
+ }
356
+ } else if (entry.type === "message" && entry.message.role === "assistant") {
357
+ // Infer default model from assistant messages
358
+ models.default = `${entry.message.provider}/${entry.message.model}`;
359
+ } else if (entry.type === "compaction") {
360
+ compaction = entry;
361
+ } else if (entry.type === "ttsr_injection") {
362
+ // Collect injected TTSR rule names
363
+ for (const ruleName of entry.injectedRules) {
364
+ injectedTtsrRulesSet.add(ruleName);
365
+ }
366
+ }
367
+ }
368
+
369
+ const injectedTtsrRules = Array.from(injectedTtsrRulesSet);
370
+
371
+ // Build messages and collect corresponding entries
372
+ // When there's a compaction, we need to:
373
+ // 1. Emit summary first (entry = compaction)
374
+ // 2. Emit kept messages (from firstKeptEntryId up to compaction)
375
+ // 3. Emit messages after compaction
376
+ const messages: AgentMessage[] = [];
377
+
378
+ const appendMessage = (entry: SessionEntry) => {
379
+ if (entry.type === "message") {
380
+ messages.push(entry.message);
381
+ } else if (entry.type === "custom_message") {
382
+ messages.push(
383
+ createHookMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp),
384
+ );
385
+ } else if (entry.type === "branch_summary" && entry.summary) {
386
+ messages.push(createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp));
387
+ }
388
+ };
389
+
390
+ if (compaction) {
391
+ // Emit summary first
392
+ messages.push(createCompactionSummaryMessage(compaction.summary, compaction.tokensBefore, compaction.timestamp));
393
+
394
+ // Find compaction index in path
395
+ const compactionIdx = path.findIndex((e) => e.type === "compaction" && e.id === compaction.id);
396
+
397
+ // Emit kept messages (before compaction, starting from firstKeptEntryId)
398
+ let foundFirstKept = false;
399
+ for (let i = 0; i < compactionIdx; i++) {
400
+ const entry = path[i];
401
+ if (entry.id === compaction.firstKeptEntryId) {
402
+ foundFirstKept = true;
403
+ }
404
+ if (foundFirstKept) {
405
+ appendMessage(entry);
406
+ }
407
+ }
408
+
409
+ // Emit messages after compaction
410
+ for (let i = compactionIdx + 1; i < path.length; i++) {
411
+ const entry = path[i];
412
+ appendMessage(entry);
413
+ }
414
+ } else {
415
+ // No compaction - emit all messages, handle branch summaries and custom messages
416
+ for (const entry of path) {
417
+ appendMessage(entry);
418
+ }
419
+ }
420
+
421
+ return { messages, thinkingLevel, models, injectedTtsrRules };
422
+ }
423
+
424
+ /**
425
+ * Compute the default session directory for a cwd.
426
+ * Encodes cwd into a safe directory name under ~/.omp/agent/sessions/.
427
+ */
428
+ function getDefaultSessionDir(cwd: string): string {
429
+ const safePath = `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
430
+ const sessionDir = join(getDefaultAgentDir(), "sessions", safePath);
431
+ if (!existsSync(sessionDir)) {
432
+ mkdirSync(sessionDir, { recursive: true });
433
+ }
434
+ return sessionDir;
435
+ }
436
+
437
+ /** Exported for testing */
438
+ export function loadEntriesFromFile(filePath: string): FileEntry[] {
439
+ if (!existsSync(filePath)) return [];
440
+
441
+ const content = readFileSync(filePath, "utf8");
442
+ const entries: FileEntry[] = [];
443
+ const lines = content.trim().split("\n");
444
+
445
+ for (const line of lines) {
446
+ if (!line.trim()) continue;
447
+ try {
448
+ const entry = JSON.parse(line) as FileEntry;
449
+ entries.push(entry);
450
+ } catch {
451
+ // Skip malformed lines
452
+ }
453
+ }
454
+
455
+ // Validate session header
456
+ if (entries.length === 0) return entries;
457
+ const header = entries[0];
458
+ if (header.type !== "session" || typeof (header as any).id !== "string") {
459
+ return [];
460
+ }
461
+
462
+ return entries;
463
+ }
464
+
465
+ /**
466
+ * Lightweight metadata for a session file, used in session picker UI.
467
+ * Uses lazy getters to defer string formatting until actually displayed.
468
+ */
469
+ class RecentSessionInfo {
470
+ readonly path: string;
471
+ readonly mtime: number;
472
+
473
+ #fullName: string | undefined;
474
+ #name: string | undefined;
475
+ #timeAgo: string | undefined;
476
+
477
+ constructor(path: string, mtime: number, header: Record<string, unknown>) {
478
+ this.path = path;
479
+ this.mtime = mtime;
480
+
481
+ // Extract title from session header, falling back to id if title is missing
482
+ const trystr = (v: unknown) => (typeof v === "string" ? v : undefined);
483
+ this.#fullName = trystr(header.title) ?? trystr(header.id);
484
+ }
485
+
486
+ /** Full session name from header, or filename without extension as fallback */
487
+ get fullName(): string {
488
+ if (this.#fullName) return this.#fullName;
489
+ this.#fullName = this.path.split("/").pop()?.replace(".jsonl", "") ?? "Unknown";
490
+ return this.#fullName;
491
+ }
492
+
493
+ /** Truncated name for display (max 40 chars) */
494
+ get name(): string {
495
+ if (this.#name) return this.#name;
496
+ const fullName = this.fullName;
497
+ this.#name = fullName.length <= 40 ? fullName : `${fullName.slice(0, 37)}...`;
498
+ return this.#name;
499
+ }
500
+
501
+ /** Human-readable relative time (e.g., "2 hours ago") */
502
+ get timeAgo(): string {
503
+ if (this.#timeAgo) return this.#timeAgo;
504
+ this.#timeAgo = formatTimeAgo(new Date(this.mtime));
505
+ return this.#timeAgo;
506
+ }
507
+ }
508
+
509
+ /**
510
+ * Reads all session files from the directory and returns them sorted by mtime (newest first).
511
+ * Uses low-level file I/O to efficiently read only the first 512 bytes of each file
512
+ * to extract the JSON header without loading entire session logs into memory.
513
+ */
514
+ function getSortedSessions(sessionDir: string): RecentSessionInfo[] {
515
+ try {
516
+ // Reusable buffer for reading file headers
517
+ const buf = Buffer.allocUnsafe(512);
518
+
519
+ /**
520
+ * Reads the first line (JSON header) from an open file descriptor.
521
+ * Returns null if the file is empty or doesn't start with valid JSON.
522
+ */
523
+ const readHeader = (fd: number) => {
524
+ const bytesRead = readSync(fd, buf, 0, 512, 0);
525
+ if (bytesRead === 0) return null;
526
+ const sub = buf.subarray(0, bytesRead);
527
+ // Quick check: first char must be '{' for valid JSON object
528
+ if (sub.at(0) !== "{".charCodeAt(0)) return null;
529
+ // Find end of first JSON line
530
+ const eol = sub.indexOf("}\n");
531
+ if (eol <= 0) return null;
532
+ const header = JSON.parse(sub.toString("utf8", 0, eol + 1));
533
+ // Validate session header
534
+ if (header.type !== "session" || typeof header.id !== "string") return null;
535
+ return header;
536
+ };
537
+
538
+ return readdirSync(sessionDir)
539
+ .map((f) => {
540
+ try {
541
+ if (!f.endsWith(".jsonl")) return null;
542
+ const path = join(sessionDir, f);
543
+ const fd = openSync(path, "r");
544
+ try {
545
+ const header = readHeader(fd);
546
+ if (!header) return null;
547
+ const mtime = statSync(path).mtimeMs;
548
+ return new RecentSessionInfo(path, mtime, header);
549
+ } finally {
550
+ closeSync(fd);
551
+ }
552
+ } catch {
553
+ return null;
554
+ }
555
+ })
556
+ .filter((x) => x !== null)
557
+ .sort((a, b) => b.mtime - a.mtime); // Sort newest first
558
+ } catch {
559
+ return [];
560
+ }
561
+ }
562
+
563
+ /** Exported for testing */
564
+ export function findMostRecentSession(sessionDir: string): string | null {
565
+ const sessions = getSortedSessions(sessionDir);
566
+ return sessions[0]?.path || null;
567
+ }
568
+
569
+ /** Format a time difference as a human-readable string */
570
+ function formatTimeAgo(date: Date): string {
571
+ const now = Date.now();
572
+ const diffMs = now - date.getTime();
573
+ const diffMins = Math.floor(diffMs / 60000);
574
+ const diffHours = Math.floor(diffMs / 3600000);
575
+ const diffDays = Math.floor(diffMs / 86400000);
576
+
577
+ if (diffMins < 1) return "just now";
578
+ if (diffMins < 60) return `${diffMins}m ago`;
579
+ if (diffHours < 24) return `${diffHours}h ago`;
580
+ if (diffDays < 7) return `${diffDays}d ago`;
581
+ return date.toLocaleDateString();
582
+ }
583
+
584
+ const MAX_PERSIST_CHARS = 500_000;
585
+ const TRUNCATION_NOTICE = "\n\n[Session persistence truncated large content]";
586
+ const PLACEHOLDER_IMAGE_DATA =
587
+ "/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAP//////////////////////////////////////////////////////////////////////////////////////2wBDAf//////////////////////////////////////////////////////////////////////////////////////wAARCAAQABADASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAf/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAgP/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCkA//Z";
588
+
589
+ const TEXT_CONTENT_KEY = "content";
590
+
591
+ function fsyncDirSync(dir: string): void {
592
+ try {
593
+ const fd = openSync(dir, "r");
594
+ try {
595
+ fsyncSync(fd);
596
+ } finally {
597
+ closeSync(fd);
598
+ }
599
+ } catch {
600
+ // Best-effort: some platforms/filesystems don't support fsync on directories.
601
+ }
602
+ }
603
+
604
+ /**
605
+ * Recursively truncate large strings in an object for session persistence.
606
+ * - Truncates any oversized string fields (key-agnostic)
607
+ * - Replaces oversized image blocks with text notices
608
+ * - Updates lineCount when content is truncated
609
+ * - Returns original object if no changes needed (structural sharing)
610
+ */
611
+ function truncateString(value: string, maxLength: number): string {
612
+ if (value.length <= maxLength) return value;
613
+ let truncated = value.slice(0, maxLength);
614
+ if (truncated.length > 0) {
615
+ const last = truncated.charCodeAt(truncated.length - 1);
616
+ if (last >= 0xd800 && last <= 0xdbff) {
617
+ truncated = truncated.slice(0, -1);
618
+ }
619
+ }
620
+ return truncated;
621
+ }
622
+
623
+ function isImageBlock(value: unknown): value is { type: "image"; data: string; mimeType?: string } {
624
+ return (
625
+ typeof value === "object" &&
626
+ value !== null &&
627
+ "type" in value &&
628
+ (value as { type?: string }).type === "image" &&
629
+ "data" in value &&
630
+ typeof (value as { data?: string }).data === "string"
631
+ );
632
+ }
633
+
634
+ async function compressImageForPersistence(image: ImageContent): Promise<ImageContent> {
635
+ try {
636
+ const buffer = Buffer.from(image.data, "base64");
637
+ const pipeline = sharp(buffer, { failOnError: false });
638
+ const metadata = await pipeline.metadata();
639
+ const width = metadata.width ?? 0;
640
+ const height = metadata.height ?? 0;
641
+ const hasDims = width > 0 && height > 0;
642
+ const targetWidth = hasDims && width >= height ? 512 : undefined;
643
+ const targetHeight = hasDims && height > width ? 512 : undefined;
644
+ const resized = await pipeline
645
+ .resize({
646
+ width: hasDims ? targetWidth : 512,
647
+ height: hasDims ? targetHeight : 512,
648
+ fit: "inside",
649
+ withoutEnlargement: true,
650
+ })
651
+ .jpeg({ quality: 70 })
652
+ .toBuffer();
653
+ const base64 = resized.toString("base64");
654
+ if (base64.length > MAX_PERSIST_CHARS) {
655
+ return { type: "image", data: PLACEHOLDER_IMAGE_DATA, mimeType: "image/jpeg" };
656
+ }
657
+ return { type: "image", data: base64, mimeType: "image/jpeg" };
658
+ } catch {
659
+ return { type: "image", data: PLACEHOLDER_IMAGE_DATA, mimeType: "image/jpeg" };
660
+ }
661
+ }
662
+
663
+ async function truncateForPersistence<T>(obj: T, key?: string): Promise<T> {
664
+ if (obj === null || obj === undefined) return obj;
665
+
666
+ if (typeof obj === "string") {
667
+ if (obj.length > MAX_PERSIST_CHARS) {
668
+ const limit = Math.max(0, MAX_PERSIST_CHARS - TRUNCATION_NOTICE.length);
669
+ return `${truncateString(obj, limit)}${TRUNCATION_NOTICE}` as T;
670
+ }
671
+ return obj;
672
+ }
673
+
674
+ if (Array.isArray(obj)) {
675
+ let changed = false;
676
+ const result = await Promise.all(
677
+ obj.map(async (item) => {
678
+ // Special handling: compress oversized images while preserving shape
679
+ if (key === TEXT_CONTENT_KEY && isImageBlock(item)) {
680
+ if (item.data.length > MAX_PERSIST_CHARS) {
681
+ changed = true;
682
+ return compressImageForPersistence({
683
+ type: "image",
684
+ data: item.data,
685
+ mimeType: item.mimeType ?? "image/jpeg",
686
+ });
687
+ }
688
+ }
689
+ const newItem = await truncateForPersistence(item, key);
690
+ if (newItem !== item) changed = true;
691
+ return newItem;
692
+ }),
693
+ );
694
+ return changed ? (result as T) : obj;
695
+ }
696
+
697
+ if (typeof obj === "object") {
698
+ let changed = false;
699
+ const result: Record<string, unknown> = {};
700
+ for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
701
+ const newV = await truncateForPersistence(v, k);
702
+ result[k] = newV;
703
+ if (newV !== v) changed = true;
704
+ }
705
+ // Update lineCount if content was truncated (for FileMentionFile)
706
+ if (changed && "lineCount" in result && "content" in result && typeof result.content === "string") {
707
+ result.lineCount = result.content.split("\n").length;
708
+ }
709
+ return changed ? (result as T) : obj;
710
+ }
711
+
712
+ return obj;
713
+ }
714
+
715
+ async function prepareEntryForPersistence(entry: FileEntry): Promise<FileEntry> {
716
+ return truncateForPersistence(entry);
717
+ }
718
+
719
+ class NdjsonFileWriter {
720
+ private writeStream: WriteStream;
721
+ private closed = false;
722
+ private closing = false;
723
+ private error: Error | undefined;
724
+ private pendingWrites: Promise<void> = Promise.resolve();
725
+ private ready: Promise<void>;
726
+ private fd: number | null = null;
727
+ private onError: ((err: Error) => void) | undefined;
728
+
729
+ constructor(path: string, options?: { flags?: string; onError?: (err: Error) => void }) {
730
+ this.onError = options?.onError;
731
+ this.writeStream = createWriteStream(path, { flags: options?.flags ?? "a" });
732
+ this.ready = new Promise<void>((resolve, reject) => {
733
+ const onOpen = (fd: number) => {
734
+ this.fd = fd;
735
+ this.writeStream.off("error", onError);
736
+ resolve();
737
+ };
738
+ const onError = (err: Error) => {
739
+ this.writeStream.off("open", onOpen);
740
+ reject(err);
741
+ };
742
+ this.writeStream.once("open", onOpen);
743
+ this.writeStream.once("error", onError);
744
+ });
745
+ this.writeStream.on("error", (err: Error) => {
746
+ const writeErr = toError(err);
747
+ if (!this.error) this.error = writeErr;
748
+ this.onError?.(writeErr);
749
+ });
750
+ }
751
+
752
+ private enqueue(task: () => Promise<void>): Promise<void> {
753
+ const run = async () => {
754
+ if (this.error) throw this.error;
755
+ await task();
756
+ };
757
+ const next = this.pendingWrites.then(run);
758
+ this.pendingWrites = next.catch((err) => {
759
+ if (!this.error) this.error = toError(err);
760
+ });
761
+ return next;
762
+ }
763
+
764
+ private async writeLine(line: string): Promise<void> {
765
+ if (this.error) throw this.error;
766
+ await new Promise<void>((resolve, reject) => {
767
+ let settled = false;
768
+ const onError = (err: Error) => {
769
+ if (settled) return;
770
+ settled = true;
771
+ const writeErr = toError(err);
772
+ if (!this.error) this.error = writeErr;
773
+ this.writeStream.off("error", onError);
774
+ reject(writeErr);
775
+ };
776
+ this.writeStream.once("error", onError);
777
+ this.writeStream.write(line, (err) => {
778
+ if (settled) return;
779
+ settled = true;
780
+ this.writeStream.off("error", onError);
781
+ if (err) {
782
+ const writeErr = toError(err);
783
+ if (!this.error) this.error = writeErr;
784
+ reject(writeErr);
785
+ } else {
786
+ resolve();
787
+ }
788
+ });
789
+ if (this.error && !settled) {
790
+ settled = true;
791
+ this.writeStream.off("error", onError);
792
+ reject(this.error);
793
+ }
794
+ });
795
+ }
796
+
797
+ /** Queue a write. Returns a promise so callers can await if needed. */
798
+ write(entry: FileEntry): Promise<void> {
799
+ if (this.closed || this.closing) throw new Error("Writer closed");
800
+ if (this.error) throw this.error;
801
+ const line = `${JSON.stringify(entry)}\n`;
802
+ return this.enqueue(() => this.writeLine(line));
803
+ }
804
+
805
+ /** Flush all buffered data to disk. Waits for all queued writes and fsync. */
806
+ async flush(): Promise<void> {
807
+ if (this.closed) return;
808
+ if (this.error) throw this.error;
809
+
810
+ await this.enqueue(async () => {});
811
+
812
+ if (this.error) throw this.error;
813
+
814
+ await this.ready;
815
+ const fd = this.fd;
816
+ if (typeof fd === "number") {
817
+ try {
818
+ fsyncSync(fd);
819
+ } catch (err) {
820
+ const fsyncErr = toError(err);
821
+ if (!this.error) this.error = fsyncErr;
822
+ throw fsyncErr;
823
+ }
824
+ }
825
+
826
+ if (this.error) throw this.error;
827
+ }
828
+
829
+ /** Close the writer, flushing all data. */
830
+ async close(): Promise<void> {
831
+ if (this.closed || this.closing) return;
832
+ this.closing = true;
833
+
834
+ let closeError: Error | undefined;
835
+ try {
836
+ await this.flush();
837
+ } catch (err) {
838
+ closeError = toError(err);
839
+ }
840
+
841
+ await this.pendingWrites;
842
+
843
+ await new Promise<void>((resolve, reject) => {
844
+ this.writeStream.end((err?: Error | null) => {
845
+ if (err) {
846
+ const endErr = toError(err);
847
+ if (!this.error) this.error = endErr;
848
+ reject(endErr);
849
+ } else {
850
+ resolve();
851
+ }
852
+ });
853
+ });
854
+
855
+ this.closed = true;
856
+ this.writeStream.removeAllListeners();
857
+
858
+ if (closeError) throw closeError;
859
+ if (this.error) throw this.error;
860
+ }
861
+
862
+ /** Check if there's a stored error. */
863
+ getError(): Error | undefined {
864
+ return this.error;
865
+ }
866
+ }
867
+
868
+ /** Get recent sessions for display in welcome screen */
869
+ export function getRecentSessions(sessionDir: string, limit = 3): RecentSessionInfo[] {
870
+ return getSortedSessions(sessionDir).slice(0, limit);
871
+ }
872
+
873
+ /**
874
+ * Manages conversation sessions as append-only trees stored in JSONL files.
875
+ *
876
+ * Each session entry has an id and parentId forming a tree structure. The "leaf"
877
+ * pointer tracks the current position. Appending creates a child of the current leaf.
878
+ * Branching moves the leaf to an earlier entry, allowing new branches without
879
+ * modifying history.
880
+ *
881
+ * Use buildSessionContext() to get the resolved message list for the LLM, which
882
+ * handles compaction summaries and follows the path from root to current leaf.
883
+ */
884
+ export interface UsageStatistics {
885
+ input: number;
886
+ output: number;
887
+ cacheRead: number;
888
+ cacheWrite: number;
889
+ cost: number;
890
+ }
891
+
892
+ function getTaskToolUsage(details: unknown): Usage | undefined {
893
+ if (!details || typeof details !== "object") return undefined;
894
+ const record = details as Record<string, unknown>;
895
+ const usage = record.usage;
896
+ if (!usage || typeof usage !== "object") return undefined;
897
+ return usage as Usage;
898
+ }
899
+
900
+ export class SessionManager {
901
+ private sessionId: string = "";
902
+ private sessionTitle: string | undefined;
903
+ private sessionFile: string | undefined;
904
+ private sessionDir: string;
905
+ private cwd: string;
906
+ private persist: boolean;
907
+ private flushed: boolean = false;
908
+ private fileEntries: FileEntry[] = [];
909
+ private byId: Map<string, SessionEntry> = new Map();
910
+ private labelsById: Map<string, string> = new Map();
911
+ private leafId: string | null = null;
912
+ private usageStatistics: UsageStatistics = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
913
+ private persistWriter: NdjsonFileWriter | undefined;
914
+ private persistWriterPath: string | undefined;
915
+ private persistChain: Promise<void> = Promise.resolve();
916
+ private persistError: Error | undefined;
917
+ private persistErrorReported = false;
918
+
919
+ private constructor(cwd: string, sessionDir: string, persist: boolean) {
920
+ this.cwd = cwd;
921
+ this.sessionDir = sessionDir;
922
+ this.persist = persist;
923
+ if (persist && sessionDir && !existsSync(sessionDir)) {
924
+ mkdirSync(sessionDir, { recursive: true });
925
+ }
926
+ // Note: call _initSession() or _initSessionFile() after construction
927
+ }
928
+
929
+ /** Initialize with a specific session file (used by factory methods) */
930
+ private async _initSessionFile(sessionFile: string): Promise<void> {
931
+ await this.setSessionFile(sessionFile);
932
+ }
933
+
934
+ /** Initialize with a new session (used by factory methods) */
935
+ private _initNewSession(): void {
936
+ this._newSessionSync();
937
+ }
938
+
939
+ /** Switch to a different session file (used for resume and branching) */
940
+ async setSessionFile(sessionFile: string): Promise<void> {
941
+ await this._closePersistWriter();
942
+ this.persistError = undefined;
943
+ this.persistErrorReported = false;
944
+ this.sessionFile = resolve(sessionFile);
945
+ if (existsSync(this.sessionFile)) {
946
+ this.fileEntries = loadEntriesFromFile(this.sessionFile);
947
+ const header = this.fileEntries.find((e) => e.type === "session") as SessionHeader | undefined;
948
+ this.sessionId = header?.id ?? nanoid();
949
+ this.sessionTitle = header?.title;
950
+
951
+ if (migrateToCurrentVersion(this.fileEntries)) {
952
+ await this._rewriteFile();
953
+ }
954
+
955
+ this._buildIndex();
956
+ this.flushed = true;
957
+ } else {
958
+ this._newSessionSync();
959
+ }
960
+ }
961
+
962
+ /** Start a new session. Closes any existing writer first. */
963
+ async newSession(options?: NewSessionOptions): Promise<string | undefined> {
964
+ await this._closePersistWriter();
965
+ return this._newSessionSync(options);
966
+ }
967
+
968
+ /** Sync version for initial creation (no existing writer to close) */
969
+ private _newSessionSync(options?: NewSessionOptions): string | undefined {
970
+ this.persistChain = Promise.resolve();
971
+ this.persistError = undefined;
972
+ this.persistErrorReported = false;
973
+ this.sessionId = nanoid();
974
+ const timestamp = new Date().toISOString();
975
+ const header: SessionHeader = {
976
+ type: "session",
977
+ version: CURRENT_SESSION_VERSION,
978
+ id: this.sessionId,
979
+ timestamp,
980
+ cwd: this.cwd,
981
+ parentSession: options?.parentSession,
982
+ };
983
+ this.fileEntries = [header];
984
+ this.byId.clear();
985
+ this.leafId = null;
986
+ this.flushed = false;
987
+ this.usageStatistics = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
988
+
989
+ // Only generate filename if persisting and not already set (e.g., via --session flag)
990
+ if (this.persist && !this.sessionFile) {
991
+ const fileTimestamp = timestamp.replace(/[:.]/g, "-");
992
+ this.sessionFile = join(this.getSessionDir(), `${fileTimestamp}_${this.sessionId}.jsonl`);
993
+ }
994
+ return this.sessionFile;
995
+ }
996
+
997
+ private _buildIndex(): void {
998
+ this.byId.clear();
999
+ this.labelsById.clear();
1000
+ this.leafId = null;
1001
+ this.usageStatistics = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
1002
+ for (const entry of this.fileEntries) {
1003
+ if (entry.type === "session") continue;
1004
+ this.byId.set(entry.id, entry);
1005
+ this.leafId = entry.id;
1006
+ if (entry.type === "label") {
1007
+ if (entry.label) {
1008
+ this.labelsById.set(entry.targetId, entry.label);
1009
+ } else {
1010
+ this.labelsById.delete(entry.targetId);
1011
+ }
1012
+ }
1013
+ if (entry.type === "message" && entry.message.role === "assistant") {
1014
+ const usage = entry.message.usage;
1015
+ this.usageStatistics.input += usage.input;
1016
+ this.usageStatistics.output += usage.output;
1017
+ this.usageStatistics.cacheRead += usage.cacheRead;
1018
+ this.usageStatistics.cacheWrite += usage.cacheWrite;
1019
+ this.usageStatistics.cost += usage.cost.total;
1020
+ }
1021
+
1022
+ if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.toolName === "task") {
1023
+ const usage = getTaskToolUsage(entry.message.details);
1024
+ if (usage) {
1025
+ this.usageStatistics.input += usage.input;
1026
+ this.usageStatistics.output += usage.output;
1027
+ this.usageStatistics.cacheRead += usage.cacheRead;
1028
+ this.usageStatistics.cacheWrite += usage.cacheWrite;
1029
+ this.usageStatistics.cost += usage.cost.total;
1030
+ }
1031
+ }
1032
+ }
1033
+ }
1034
+
1035
+ private _recordPersistError(err: unknown): Error {
1036
+ const normalized = toError(err);
1037
+ if (!this.persistError) this.persistError = normalized;
1038
+ if (!this.persistErrorReported) {
1039
+ this.persistErrorReported = true;
1040
+ console.error("Session persistence error:", normalized);
1041
+ }
1042
+ return normalized;
1043
+ }
1044
+
1045
+ private _queuePersistTask(task: () => Promise<void>, options?: { ignoreError?: boolean }): Promise<void> {
1046
+ const next = this.persistChain.then(async () => {
1047
+ if (this.persistError && !options?.ignoreError) throw this.persistError;
1048
+ await task();
1049
+ });
1050
+ this.persistChain = next.catch((err) => {
1051
+ this._recordPersistError(err);
1052
+ });
1053
+ return next;
1054
+ }
1055
+
1056
+ private _ensurePersistWriter(): NdjsonFileWriter | undefined {
1057
+ if (!this.persist || !this.sessionFile) return undefined;
1058
+ if (this.persistError) throw this.persistError;
1059
+ if (this.persistWriter && this.persistWriterPath === this.sessionFile) return this.persistWriter;
1060
+ // Note: caller must await _closePersistWriter() before calling this if switching files
1061
+ this.persistWriter = new NdjsonFileWriter(this.sessionFile, {
1062
+ onError: (err) => {
1063
+ this._recordPersistError(err);
1064
+ },
1065
+ });
1066
+ this.persistWriterPath = this.sessionFile;
1067
+ return this.persistWriter;
1068
+ }
1069
+
1070
+ private async _closePersistWriterInternal(): Promise<void> {
1071
+ if (this.persistWriter) {
1072
+ await this.persistWriter.close();
1073
+ this.persistWriter = undefined;
1074
+ }
1075
+ this.persistWriterPath = undefined;
1076
+ }
1077
+
1078
+ private async _closePersistWriter(): Promise<void> {
1079
+ await this._queuePersistTask(
1080
+ async () => {
1081
+ await this._closePersistWriterInternal();
1082
+ },
1083
+ { ignoreError: true },
1084
+ );
1085
+ }
1086
+
1087
+ private async _writeEntriesAtomically(entries: FileEntry[]): Promise<void> {
1088
+ if (!this.sessionFile) return;
1089
+ const dir = resolve(this.sessionFile, "..");
1090
+ const tempPath = join(dir, `.${basename(this.sessionFile)}.${nanoid(6)}.tmp`);
1091
+ const writer = new NdjsonFileWriter(tempPath, { flags: "w" });
1092
+ for (const entry of entries) {
1093
+ await writer.write(entry);
1094
+ }
1095
+ await writer.flush();
1096
+ await writer.close();
1097
+ try {
1098
+ renameSync(tempPath, this.sessionFile);
1099
+ fsyncDirSync(dir);
1100
+ } catch (err) {
1101
+ try {
1102
+ unlinkSync(tempPath);
1103
+ } catch {
1104
+ // Ignore cleanup errors
1105
+ }
1106
+ throw toError(err);
1107
+ }
1108
+ }
1109
+
1110
+ private async _rewriteFile(): Promise<void> {
1111
+ if (!this.persist || !this.sessionFile) return;
1112
+ await this._queuePersistTask(async () => {
1113
+ await this._closePersistWriterInternal();
1114
+ const entries = await Promise.all(this.fileEntries.map((entry) => prepareEntryForPersistence(entry)));
1115
+ await this._writeEntriesAtomically(entries);
1116
+ this.flushed = true;
1117
+ });
1118
+ }
1119
+
1120
+ isPersisted(): boolean {
1121
+ return this.persist;
1122
+ }
1123
+
1124
+ /** Flush pending writes to disk. Call before switching sessions or on shutdown. */
1125
+ async flush(): Promise<void> {
1126
+ if (!this.persistWriter) return;
1127
+ await this._queuePersistTask(async () => {
1128
+ if (this.persistWriter) await this.persistWriter.flush();
1129
+ });
1130
+ if (this.persistError) throw this.persistError;
1131
+ }
1132
+
1133
+ getCwd(): string {
1134
+ return this.cwd;
1135
+ }
1136
+
1137
+ /** Get usage statistics across all assistant messages in the session. */
1138
+ getUsageStatistics(): UsageStatistics {
1139
+ return this.usageStatistics;
1140
+ }
1141
+
1142
+ getSessionDir(): string {
1143
+ return this.sessionDir;
1144
+ }
1145
+
1146
+ getSessionId(): string {
1147
+ return this.sessionId;
1148
+ }
1149
+
1150
+ getSessionFile(): string | undefined {
1151
+ return this.sessionFile;
1152
+ }
1153
+
1154
+ getSessionTitle(): string | undefined {
1155
+ return this.sessionTitle;
1156
+ }
1157
+
1158
+ async setSessionTitle(title: string): Promise<void> {
1159
+ this.sessionTitle = title;
1160
+
1161
+ // Update the in-memory header (so first flush includes title)
1162
+ const header = this.fileEntries.find((e) => e.type === "session") as SessionHeader | undefined;
1163
+ if (header) {
1164
+ header.title = title;
1165
+ }
1166
+
1167
+ // Update the session file header with the title (if already flushed)
1168
+ const sessionFile = this.sessionFile;
1169
+ if (this.persist && sessionFile && existsSync(sessionFile)) {
1170
+ await this._queuePersistTask(async () => {
1171
+ await this._closePersistWriterInternal();
1172
+ try {
1173
+ const content = readFileSync(sessionFile, "utf-8");
1174
+ const lines = content.split("\n");
1175
+ if (lines.length > 0) {
1176
+ const fileHeader = JSON.parse(lines[0]) as SessionHeader;
1177
+ if (fileHeader.type === "session") {
1178
+ fileHeader.title = title;
1179
+ lines[0] = JSON.stringify(fileHeader);
1180
+ const tempPath = join(resolve(sessionFile, ".."), `.${basename(sessionFile)}.${nanoid(6)}.tmp`);
1181
+ writeFileSync(tempPath, lines.join("\n"));
1182
+ const fd = openSync(tempPath, "r");
1183
+ try {
1184
+ fsyncSync(fd);
1185
+ } finally {
1186
+ closeSync(fd);
1187
+ }
1188
+ try {
1189
+ renameSync(tempPath, sessionFile);
1190
+ fsyncDirSync(resolve(sessionFile, ".."));
1191
+ } catch (err) {
1192
+ try {
1193
+ unlinkSync(tempPath);
1194
+ } catch {
1195
+ // Ignore cleanup errors
1196
+ }
1197
+ throw err;
1198
+ }
1199
+ }
1200
+ }
1201
+ } catch (err) {
1202
+ this._recordPersistError(err);
1203
+ throw err;
1204
+ }
1205
+ });
1206
+ }
1207
+ }
1208
+
1209
+ _persist(entry: SessionEntry): void {
1210
+ if (!this.persist || !this.sessionFile) return;
1211
+ if (this.persistError) throw this.persistError;
1212
+
1213
+ const hasAssistant = this.fileEntries.some((e) => e.type === "message" && e.message.role === "assistant");
1214
+ if (!hasAssistant) return;
1215
+
1216
+ if (!this.flushed) {
1217
+ this.flushed = true;
1218
+ void this._queuePersistTask(async () => {
1219
+ const writer = this._ensurePersistWriter();
1220
+ if (!writer) return;
1221
+ const entries = await Promise.all(this.fileEntries.map((e) => prepareEntryForPersistence(e)));
1222
+ for (const persistedEntry of entries) {
1223
+ await writer.write(persistedEntry);
1224
+ }
1225
+ });
1226
+ } else {
1227
+ void this._queuePersistTask(async () => {
1228
+ const writer = this._ensurePersistWriter();
1229
+ if (!writer) return;
1230
+ const persistedEntry = await prepareEntryForPersistence(entry);
1231
+ await writer.write(persistedEntry);
1232
+ });
1233
+ }
1234
+ }
1235
+
1236
+ private _appendEntry(entry: SessionEntry): void {
1237
+ this.fileEntries.push(entry);
1238
+ this.byId.set(entry.id, entry);
1239
+ this.leafId = entry.id;
1240
+ this._persist(entry);
1241
+ if (entry.type === "message" && entry.message.role === "assistant") {
1242
+ const usage = entry.message.usage;
1243
+ this.usageStatistics.input += usage.input;
1244
+ this.usageStatistics.output += usage.output;
1245
+ this.usageStatistics.cacheRead += usage.cacheRead;
1246
+ this.usageStatistics.cacheWrite += usage.cacheWrite;
1247
+ this.usageStatistics.cost += usage.cost.total;
1248
+ }
1249
+
1250
+ if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.toolName === "task") {
1251
+ const usage = getTaskToolUsage(entry.message.details);
1252
+ if (usage) {
1253
+ this.usageStatistics.input += usage.input;
1254
+ this.usageStatistics.output += usage.output;
1255
+ this.usageStatistics.cacheRead += usage.cacheRead;
1256
+ this.usageStatistics.cacheWrite += usage.cacheWrite;
1257
+ this.usageStatistics.cost += usage.cost.total;
1258
+ }
1259
+ }
1260
+ }
1261
+
1262
+ /** Append a message as child of current leaf, then advance leaf. Returns entry id.
1263
+ * Does not allow writing CompactionSummaryMessage and BranchSummaryMessage directly.
1264
+ * Reason: we want these to be top-level entries in the session, not message session entries,
1265
+ * so it is easier to find them.
1266
+ * These need to be appended via appendCompaction() and appendBranchSummary() methods.
1267
+ */
1268
+ appendMessage(message: Message | HookMessage | BashExecutionMessage): string {
1269
+ const entry: SessionMessageEntry = {
1270
+ type: "message",
1271
+ id: generateId(this.byId),
1272
+ parentId: this.leafId,
1273
+ timestamp: new Date().toISOString(),
1274
+ message,
1275
+ };
1276
+ this._appendEntry(entry);
1277
+ return entry.id;
1278
+ }
1279
+
1280
+ /** Append a thinking level change as child of current leaf, then advance leaf. Returns entry id. */
1281
+ appendThinkingLevelChange(thinkingLevel: string): string {
1282
+ const entry: ThinkingLevelChangeEntry = {
1283
+ type: "thinking_level_change",
1284
+ id: generateId(this.byId),
1285
+ parentId: this.leafId,
1286
+ timestamp: new Date().toISOString(),
1287
+ thinkingLevel,
1288
+ };
1289
+ this._appendEntry(entry);
1290
+ return entry.id;
1291
+ }
1292
+
1293
+ /**
1294
+ * Append a model change as child of current leaf, then advance leaf. Returns entry id.
1295
+ * @param model Model in "provider/modelId" format
1296
+ * @param role Optional role (default: "default")
1297
+ */
1298
+ appendModelChange(model: string, role?: string): string {
1299
+ const entry: ModelChangeEntry = {
1300
+ type: "model_change",
1301
+ id: generateId(this.byId),
1302
+ parentId: this.leafId,
1303
+ timestamp: new Date().toISOString(),
1304
+ model,
1305
+ role,
1306
+ };
1307
+ this._appendEntry(entry);
1308
+ return entry.id;
1309
+ }
1310
+
1311
+ /** Append a compaction summary as child of current leaf, then advance leaf. Returns entry id. */
1312
+ appendCompaction<T = unknown>(
1313
+ summary: string,
1314
+ firstKeptEntryId: string,
1315
+ tokensBefore: number,
1316
+ details?: T,
1317
+ fromHook?: boolean,
1318
+ ): string {
1319
+ const entry: CompactionEntry<T> = {
1320
+ type: "compaction",
1321
+ id: generateId(this.byId),
1322
+ parentId: this.leafId,
1323
+ timestamp: new Date().toISOString(),
1324
+ summary,
1325
+ firstKeptEntryId,
1326
+ tokensBefore,
1327
+ details,
1328
+ fromHook,
1329
+ };
1330
+ this._appendEntry(entry);
1331
+ return entry.id;
1332
+ }
1333
+
1334
+ /** Append a custom entry (for hooks) as child of current leaf, then advance leaf. Returns entry id. */
1335
+ appendCustomEntry(customType: string, data?: unknown): string {
1336
+ const entry: CustomEntry = {
1337
+ type: "custom",
1338
+ customType,
1339
+ data,
1340
+ id: generateId(this.byId),
1341
+ parentId: this.leafId,
1342
+ timestamp: new Date().toISOString(),
1343
+ };
1344
+ this._appendEntry(entry);
1345
+ return entry.id;
1346
+ }
1347
+
1348
+ /**
1349
+ * Append a custom message entry (for hooks) that participates in LLM context.
1350
+ * @param customType Hook identifier for filtering on reload
1351
+ * @param content Message content (string or TextContent/ImageContent array)
1352
+ * @param display Whether to show in TUI (true = styled display, false = hidden)
1353
+ * @param details Optional hook-specific metadata (not sent to LLM)
1354
+ * @returns Entry id
1355
+ */
1356
+ appendCustomMessageEntry<T = unknown>(
1357
+ customType: string,
1358
+ content: string | (TextContent | ImageContent)[],
1359
+ display: boolean,
1360
+ details?: T,
1361
+ ): string {
1362
+ const entry: CustomMessageEntry<T> = {
1363
+ type: "custom_message",
1364
+ customType,
1365
+ content,
1366
+ display,
1367
+ details,
1368
+ id: generateId(this.byId),
1369
+ parentId: this.leafId,
1370
+ timestamp: new Date().toISOString(),
1371
+ };
1372
+ this._appendEntry(entry);
1373
+ return entry.id;
1374
+ }
1375
+
1376
+ // =========================================================================
1377
+ // TTSR (Time Traveling Stream Rules)
1378
+ // =========================================================================
1379
+
1380
+ /**
1381
+ * Append a TTSR injection entry recording which rules were injected.
1382
+ * @param ruleNames Names of rules that were injected
1383
+ * @returns Entry id
1384
+ */
1385
+ appendTtsrInjection(ruleNames: string[]): string {
1386
+ const entry: TtsrInjectionEntry = {
1387
+ type: "ttsr_injection",
1388
+ id: generateId(this.byId),
1389
+ parentId: this.leafId,
1390
+ timestamp: new Date().toISOString(),
1391
+ injectedRules: ruleNames,
1392
+ };
1393
+ this._appendEntry(entry);
1394
+ return entry.id;
1395
+ }
1396
+
1397
+ /**
1398
+ * Get all unique TTSR rule names that have been injected in the current branch.
1399
+ * Scans from root to current leaf for ttsr_injection entries.
1400
+ */
1401
+ getInjectedTtsrRules(): string[] {
1402
+ const path = this.getBranch();
1403
+ const ruleNames = new Set<string>();
1404
+ for (const entry of path) {
1405
+ if (entry.type === "ttsr_injection") {
1406
+ for (const name of entry.injectedRules) {
1407
+ ruleNames.add(name);
1408
+ }
1409
+ }
1410
+ }
1411
+ return Array.from(ruleNames);
1412
+ }
1413
+
1414
+ // =========================================================================
1415
+ // Tree Traversal
1416
+ // =========================================================================
1417
+
1418
+ getLeafId(): string | null {
1419
+ return this.leafId;
1420
+ }
1421
+
1422
+ getLeafEntry(): SessionEntry | undefined {
1423
+ return this.leafId ? this.byId.get(this.leafId) : undefined;
1424
+ }
1425
+
1426
+ getEntry(id: string): SessionEntry | undefined {
1427
+ return this.byId.get(id);
1428
+ }
1429
+
1430
+ /**
1431
+ * Get all direct children of an entry.
1432
+ */
1433
+ getChildren(parentId: string): SessionEntry[] {
1434
+ const children: SessionEntry[] = [];
1435
+ for (const entry of this.byId.values()) {
1436
+ if (entry.parentId === parentId) {
1437
+ children.push(entry);
1438
+ }
1439
+ }
1440
+ return children;
1441
+ }
1442
+
1443
+ /**
1444
+ * Get the label for an entry, if any.
1445
+ */
1446
+ getLabel(id: string): string | undefined {
1447
+ return this.labelsById.get(id);
1448
+ }
1449
+
1450
+ /**
1451
+ * Set or clear a label on an entry.
1452
+ * Labels are user-defined markers for bookmarking/navigation.
1453
+ * Pass undefined or empty string to clear the label.
1454
+ */
1455
+ appendLabelChange(targetId: string, label: string | undefined): string {
1456
+ if (!this.byId.has(targetId)) {
1457
+ throw new Error(`Entry ${targetId} not found`);
1458
+ }
1459
+ const entry: LabelEntry = {
1460
+ type: "label",
1461
+ id: generateId(this.byId),
1462
+ parentId: this.leafId,
1463
+ timestamp: new Date().toISOString(),
1464
+ targetId,
1465
+ label,
1466
+ };
1467
+ this._appendEntry(entry);
1468
+ if (label) {
1469
+ this.labelsById.set(targetId, label);
1470
+ } else {
1471
+ this.labelsById.delete(targetId);
1472
+ }
1473
+ return entry.id;
1474
+ }
1475
+
1476
+ /**
1477
+ * Walk from entry to root, returning all entries in path order.
1478
+ * Includes all entry types (messages, compaction, model changes, etc.).
1479
+ * Use buildSessionContext() to get the resolved messages for the LLM.
1480
+ */
1481
+ getBranch(fromId?: string): SessionEntry[] {
1482
+ const path: SessionEntry[] = [];
1483
+ const startId = fromId ?? this.leafId;
1484
+ let current = startId ? this.byId.get(startId) : undefined;
1485
+ while (current) {
1486
+ path.unshift(current);
1487
+ current = current.parentId ? this.byId.get(current.parentId) : undefined;
1488
+ }
1489
+ return path;
1490
+ }
1491
+
1492
+ /**
1493
+ * Build the session context (what gets sent to the LLM).
1494
+ * Uses tree traversal from current leaf.
1495
+ */
1496
+ buildSessionContext(): SessionContext {
1497
+ return buildSessionContext(this.getEntries(), this.leafId, this.byId);
1498
+ }
1499
+
1500
+ /**
1501
+ * Get session header.
1502
+ */
1503
+ getHeader(): SessionHeader | null {
1504
+ const h = this.fileEntries.find((e) => e.type === "session");
1505
+ return h ? (h as SessionHeader) : null;
1506
+ }
1507
+
1508
+ /**
1509
+ * Get all session entries (excludes header). Returns a shallow copy.
1510
+ * The session is append-only: use appendXXX() to add entries, branch() to
1511
+ * change the leaf pointer. Entries cannot be modified or deleted.
1512
+ */
1513
+ getEntries(): SessionEntry[] {
1514
+ return this.fileEntries.filter((e): e is SessionEntry => e.type !== "session");
1515
+ }
1516
+
1517
+ /**
1518
+ * Get the session as a tree structure. Returns a shallow defensive copy of all entries.
1519
+ * A well-formed session has exactly one root (first entry with parentId === null).
1520
+ * Orphaned entries (broken parent chain) are also returned as roots.
1521
+ */
1522
+ getTree(): SessionTreeNode[] {
1523
+ const entries = this.getEntries();
1524
+ const nodeMap = new Map<string, SessionTreeNode>();
1525
+ const roots: SessionTreeNode[] = [];
1526
+
1527
+ // Create nodes with resolved labels
1528
+ for (const entry of entries) {
1529
+ const label = this.labelsById.get(entry.id);
1530
+ nodeMap.set(entry.id, { entry, children: [], label });
1531
+ }
1532
+
1533
+ // Build tree
1534
+ for (const entry of entries) {
1535
+ const node = nodeMap.get(entry.id)!;
1536
+ if (entry.parentId === null || entry.parentId === entry.id) {
1537
+ roots.push(node);
1538
+ } else {
1539
+ const parent = nodeMap.get(entry.parentId);
1540
+ if (parent) {
1541
+ parent.children.push(node);
1542
+ } else {
1543
+ // Orphan - treat as root
1544
+ roots.push(node);
1545
+ }
1546
+ }
1547
+ }
1548
+
1549
+ // Sort children by timestamp (oldest first, newest at bottom)
1550
+ // Use iterative approach to avoid stack overflow on deep trees
1551
+ const stack: SessionTreeNode[] = [...roots];
1552
+ while (stack.length > 0) {
1553
+ const node = stack.pop()!;
1554
+ node.children.sort((a, b) => new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime());
1555
+ stack.push(...node.children);
1556
+ }
1557
+
1558
+ return roots;
1559
+ }
1560
+
1561
+ // =========================================================================
1562
+ // Branching
1563
+ // =========================================================================
1564
+
1565
+ /**
1566
+ * Start a new branch from an earlier entry.
1567
+ * Moves the leaf pointer to the specified entry. The next appendXXX() call
1568
+ * will create a child of that entry, forming a new branch. Existing entries
1569
+ * are not modified or deleted.
1570
+ */
1571
+ branch(branchFromId: string): void {
1572
+ if (!this.byId.has(branchFromId)) {
1573
+ throw new Error(`Entry ${branchFromId} not found`);
1574
+ }
1575
+ this.leafId = branchFromId;
1576
+ }
1577
+
1578
+ /**
1579
+ * Reset the leaf pointer to null (before any entries).
1580
+ * The next appendXXX() call will create a new root entry (parentId = null).
1581
+ * Use this when navigating to re-edit the first user message.
1582
+ */
1583
+ resetLeaf(): void {
1584
+ this.leafId = null;
1585
+ }
1586
+
1587
+ /**
1588
+ * Start a new branch with a summary of the abandoned path.
1589
+ * Same as branch(), but also appends a branch_summary entry that captures
1590
+ * context from the abandoned conversation path.
1591
+ */
1592
+ branchWithSummary(branchFromId: string | null, summary: string, details?: unknown, fromHook?: boolean): string {
1593
+ if (branchFromId !== null && !this.byId.has(branchFromId)) {
1594
+ throw new Error(`Entry ${branchFromId} not found`);
1595
+ }
1596
+ this.leafId = branchFromId;
1597
+ const entry: BranchSummaryEntry = {
1598
+ type: "branch_summary",
1599
+ id: generateId(this.byId),
1600
+ parentId: branchFromId,
1601
+ timestamp: new Date().toISOString(),
1602
+ fromId: branchFromId ?? "root",
1603
+ summary,
1604
+ details,
1605
+ fromHook,
1606
+ };
1607
+ this._appendEntry(entry);
1608
+ return entry.id;
1609
+ }
1610
+
1611
+ /**
1612
+ * Create a new session file containing only the path from root to the specified leaf.
1613
+ * Useful for extracting a single conversation path from a branched session.
1614
+ * Returns the new session file path, or undefined if not persisting.
1615
+ */
1616
+ createBranchedSession(leafId: string): string | undefined {
1617
+ const path = this.getBranch(leafId);
1618
+ if (path.length === 0) {
1619
+ throw new Error(`Entry ${leafId} not found`);
1620
+ }
1621
+
1622
+ // Filter out LabelEntry from path - we'll recreate them from the resolved map
1623
+ const pathWithoutLabels = path.filter((e) => e.type !== "label");
1624
+
1625
+ const newSessionId = nanoid();
1626
+ const timestamp = new Date().toISOString();
1627
+ const fileTimestamp = timestamp.replace(/[:.]/g, "-");
1628
+ const newSessionFile = join(this.getSessionDir(), `${fileTimestamp}_${newSessionId}.jsonl`);
1629
+
1630
+ const header: SessionHeader = {
1631
+ type: "session",
1632
+ version: CURRENT_SESSION_VERSION,
1633
+ id: newSessionId,
1634
+ timestamp,
1635
+ cwd: this.cwd,
1636
+ parentSession: this.persist ? this.sessionFile : undefined,
1637
+ };
1638
+
1639
+ // Collect labels for entries in the path
1640
+ const pathEntryIds = new Set(pathWithoutLabels.map((e) => e.id));
1641
+ const labelsToWrite: Array<{ targetId: string; label: string }> = [];
1642
+ for (const [targetId, label] of this.labelsById) {
1643
+ if (pathEntryIds.has(targetId)) {
1644
+ labelsToWrite.push({ targetId, label });
1645
+ }
1646
+ }
1647
+
1648
+ if (this.persist) {
1649
+ appendFileSync(newSessionFile, `${JSON.stringify(header)}\n`);
1650
+ for (const entry of pathWithoutLabels) {
1651
+ appendFileSync(newSessionFile, `${JSON.stringify(entry)}\n`);
1652
+ }
1653
+ // Write fresh label entries at the end
1654
+ const lastEntryId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null;
1655
+ let parentId = lastEntryId;
1656
+ const labelEntries: LabelEntry[] = [];
1657
+ for (const { targetId, label } of labelsToWrite) {
1658
+ const labelEntry: LabelEntry = {
1659
+ type: "label",
1660
+ id: generateId(new Set(pathEntryIds)),
1661
+ parentId,
1662
+ timestamp: new Date().toISOString(),
1663
+ targetId,
1664
+ label,
1665
+ };
1666
+ appendFileSync(newSessionFile, `${JSON.stringify(labelEntry)}\n`);
1667
+ pathEntryIds.add(labelEntry.id);
1668
+ labelEntries.push(labelEntry);
1669
+ parentId = labelEntry.id;
1670
+ }
1671
+ this.fileEntries = [header, ...pathWithoutLabels, ...labelEntries];
1672
+ this.sessionId = newSessionId;
1673
+ this._buildIndex();
1674
+ return newSessionFile;
1675
+ }
1676
+
1677
+ // In-memory mode: replace current session with the path + labels
1678
+ const labelEntries: LabelEntry[] = [];
1679
+ let parentId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null;
1680
+ for (const { targetId, label } of labelsToWrite) {
1681
+ const labelEntry: LabelEntry = {
1682
+ type: "label",
1683
+ id: generateId(new Set([...pathEntryIds, ...labelEntries.map((e) => e.id)])),
1684
+ parentId,
1685
+ timestamp: new Date().toISOString(),
1686
+ targetId,
1687
+ label,
1688
+ };
1689
+ labelEntries.push(labelEntry);
1690
+ parentId = labelEntry.id;
1691
+ }
1692
+ this.fileEntries = [header, ...pathWithoutLabels, ...labelEntries];
1693
+ this.sessionId = newSessionId;
1694
+ this._buildIndex();
1695
+ return undefined;
1696
+ }
1697
+
1698
+ /**
1699
+ * Create a new session.
1700
+ * @param cwd Working directory (stored in session header)
1701
+ * @param sessionDir Optional session directory. If omitted, uses default (~/.omp/agent/sessions/<encoded-cwd>/).
1702
+ */
1703
+ static create(cwd: string, sessionDir?: string): SessionManager {
1704
+ const dir = sessionDir ?? getDefaultSessionDir(cwd);
1705
+ const manager = new SessionManager(cwd, dir, true);
1706
+ manager._initNewSession();
1707
+ return manager;
1708
+ }
1709
+
1710
+ /**
1711
+ * Open a specific session file.
1712
+ * @param path Path to session file
1713
+ * @param sessionDir Optional session directory for /new or /branch. If omitted, derives from file's parent.
1714
+ */
1715
+ static async open(path: string, sessionDir?: string): Promise<SessionManager> {
1716
+ // Extract cwd from session header if possible, otherwise use process.cwd()
1717
+ const entries = loadEntriesFromFile(path);
1718
+ const header = entries.find((e) => e.type === "session") as SessionHeader | undefined;
1719
+ const cwd = header?.cwd ?? process.cwd();
1720
+ // If no sessionDir provided, derive from file's parent directory
1721
+ const dir = sessionDir ?? resolve(path, "..");
1722
+ const manager = new SessionManager(cwd, dir, true);
1723
+ await manager._initSessionFile(path);
1724
+ return manager;
1725
+ }
1726
+
1727
+ /**
1728
+ * Continue the most recent session, or create new if none.
1729
+ * @param cwd Working directory
1730
+ * @param sessionDir Optional session directory. If omitted, uses default (~/.omp/agent/sessions/<encoded-cwd>/).
1731
+ */
1732
+ static async continueRecent(cwd: string, sessionDir?: string): Promise<SessionManager> {
1733
+ const dir = sessionDir ?? getDefaultSessionDir(cwd);
1734
+ const mostRecent = findMostRecentSession(dir);
1735
+ const manager = new SessionManager(cwd, dir, true);
1736
+ if (mostRecent) {
1737
+ await manager._initSessionFile(mostRecent);
1738
+ } else {
1739
+ manager._initNewSession();
1740
+ }
1741
+ return manager;
1742
+ }
1743
+
1744
+ /** Create an in-memory session (no file persistence) */
1745
+ static inMemory(cwd: string = process.cwd()): SessionManager {
1746
+ const manager = new SessionManager(cwd, "", false);
1747
+ manager._initNewSession();
1748
+ return manager;
1749
+ }
1750
+
1751
+ /**
1752
+ * List all sessions.
1753
+ * @param cwd Working directory (used to compute default session directory)
1754
+ * @param sessionDir Optional session directory. If omitted, uses default (~/.omp/agent/sessions/<encoded-cwd>/).
1755
+ */
1756
+ static list(cwd: string, sessionDir?: string): SessionInfo[] {
1757
+ const dir = sessionDir ?? getDefaultSessionDir(cwd);
1758
+ const sessions: SessionInfo[] = [];
1759
+
1760
+ try {
1761
+ const files = readdirSync(dir)
1762
+ .filter((f) => f.endsWith(".jsonl"))
1763
+ .map((f) => join(dir, f));
1764
+
1765
+ for (const file of files) {
1766
+ try {
1767
+ const content = readFileSync(file, "utf8");
1768
+ const lines = content.trim().split("\n");
1769
+ if (lines.length === 0) continue;
1770
+
1771
+ // Check first line for valid session header
1772
+ let header: { type: string; id: string; title?: string; timestamp: string } | null = null;
1773
+ try {
1774
+ const first = JSON.parse(lines[0]);
1775
+ if (first.type === "session" && first.id) {
1776
+ header = first;
1777
+ }
1778
+ } catch {
1779
+ // Not valid JSON
1780
+ }
1781
+ if (!header) continue;
1782
+
1783
+ const stats = statSync(file);
1784
+ let messageCount = 0;
1785
+ let firstMessage = "";
1786
+ const allMessages: string[] = [];
1787
+
1788
+ for (let i = 1; i < lines.length; i++) {
1789
+ try {
1790
+ const entry = JSON.parse(lines[i]);
1791
+
1792
+ if (entry.type === "message") {
1793
+ messageCount++;
1794
+
1795
+ if (entry.message.role === "user" || entry.message.role === "assistant") {
1796
+ const textContent = entry.message.content
1797
+ .filter((c: any) => c.type === "text")
1798
+ .map((c: any) => c.text)
1799
+ .join(" ");
1800
+
1801
+ if (textContent) {
1802
+ allMessages.push(textContent);
1803
+
1804
+ if (!firstMessage && entry.message.role === "user") {
1805
+ firstMessage = textContent;
1806
+ }
1807
+ }
1808
+ }
1809
+ }
1810
+ } catch {
1811
+ // Skip malformed lines
1812
+ }
1813
+ }
1814
+
1815
+ sessions.push({
1816
+ path: file,
1817
+ id: header.id,
1818
+ title: header.title,
1819
+ created: new Date(header.timestamp),
1820
+ modified: stats.mtime,
1821
+ messageCount,
1822
+ firstMessage: firstMessage || "(no messages)",
1823
+ allMessagesText: allMessages.join(" "),
1824
+ });
1825
+ } catch {
1826
+ // Skip files that can't be read
1827
+ }
1828
+ }
1829
+
1830
+ sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());
1831
+ } catch {
1832
+ // Return empty list on error
1833
+ }
1834
+
1835
+ return sessions;
1836
+ }
1837
+ }