@gajae-code/coding-agent 0.11.1 → 0.11.2

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 (404) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/dist/types/cli/config-cli.d.ts +11 -1
  3. package/dist/types/cli/notify-cli.d.ts +1 -0
  4. package/dist/types/cli/session-picker.d.ts +2 -1
  5. package/dist/types/cli/setup-cli.d.ts +1 -1
  6. package/dist/types/commands/mcp-serve.d.ts +33 -0
  7. package/dist/types/commands/mcp.d.ts +1 -1
  8. package/dist/types/commands/notify.d.ts +0 -2
  9. package/dist/types/commands/sdk.d.ts +1 -1
  10. package/dist/types/config/atomic-yaml-patch.d.ts +18 -0
  11. package/dist/types/config/file-lock.d.ts +14 -4
  12. package/dist/types/config/keybindings.d.ts +42 -1
  13. package/dist/types/config/model-auth.d.ts +3 -0
  14. package/dist/types/config/model-bindings-applier.d.ts +13 -0
  15. package/dist/types/config/model-discovery-manager.d.ts +49 -0
  16. package/dist/types/config/model-equivalence.d.ts +20 -0
  17. package/dist/types/config/model-registry.d.ts +16 -15
  18. package/dist/types/config/model-resolver.d.ts +28 -18
  19. package/dist/types/config/models-config-schema.d.ts +11 -0
  20. package/dist/types/config/settings-schema.d.ts +56 -1
  21. package/dist/types/config/settings.d.ts +14 -11
  22. package/dist/types/coordinator-mcp/policy.d.ts +5 -0
  23. package/dist/types/coordinator-mcp/question-gate-codec.d.ts +129 -0
  24. package/dist/types/coordinator-mcp/question-state.d.ts +356 -0
  25. package/dist/types/coordinator-mcp/server.d.ts +4 -0
  26. package/dist/types/discovery/helpers.d.ts +0 -6
  27. package/dist/types/edit/index.d.ts +3 -0
  28. package/dist/types/eval/py/env.d.ts +9 -0
  29. package/dist/types/extensibility/extensions/loader.d.ts +2 -1
  30. package/dist/types/extensibility/extensions/runner.d.ts +2 -2
  31. package/dist/types/extensibility/extensions/types.d.ts +34 -3
  32. package/dist/types/extensibility/hooks/runner.d.ts +1 -1
  33. package/dist/types/extensibility/shared-events.d.ts +2 -0
  34. package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +10 -1
  35. package/dist/types/gjc-runtime/deep-interview-state.d.ts +44 -0
  36. package/dist/types/gjc-runtime/goal-mode-request.d.ts +11 -1
  37. package/dist/types/gjc-runtime/launch-worktree.d.ts +4 -2
  38. package/dist/types/gjc-runtime/linux-proc.d.ts +29 -0
  39. package/dist/types/gjc-runtime/session-resolution.d.ts +2 -2
  40. package/dist/types/gjc-runtime/state-migrations.d.ts +1 -1
  41. package/dist/types/gjc-runtime/state-writer.d.ts +2 -1
  42. package/dist/types/gjc-runtime/team-launch.d.ts +59 -0
  43. package/dist/types/gjc-runtime/team-notify.d.ts +45 -0
  44. package/dist/types/gjc-runtime/team-runtime.d.ts +29 -121
  45. package/dist/types/gjc-runtime/team-store.d.ts +160 -0
  46. package/dist/types/gjc-runtime/team-workers.d.ts +78 -0
  47. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +8 -0
  48. package/dist/types/gjc-runtime/ultragoal-change-set.d.ts +13 -0
  49. package/dist/types/gjc-runtime/ultragoal-evidence.d.ts +38 -0
  50. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +7 -0
  51. package/dist/types/gjc-runtime/ultragoal-pipeline.d.ts +23 -0
  52. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +55 -33
  53. package/dist/types/gjc-runtime/workflow-cli-common.d.ts +8 -0
  54. package/dist/types/gjc-runtime/workflow-manifest.d.ts +3 -0
  55. package/dist/types/goals/continuation-timeout-guard.d.ts +22 -0
  56. package/dist/types/goals/runtime.d.ts +1 -0
  57. package/dist/types/goals/state.d.ts +8 -0
  58. package/dist/types/harness-control-plane/lifecycle-reconciliation.d.ts +17 -0
  59. package/dist/types/harness-control-plane/phase-rollup.d.ts +4 -7
  60. package/dist/types/harness-control-plane/receipt-ingest.d.ts +5 -0
  61. package/dist/types/harness-control-plane/receipt-spool.d.ts +4 -0
  62. package/dist/types/hindsight/state.d.ts +11 -4
  63. package/dist/types/lsp/lspmux.d.ts +5 -3
  64. package/dist/types/lsp/path-trust.d.ts +1 -0
  65. package/dist/types/main.d.ts +3 -2
  66. package/dist/types/memories/storage.d.ts +1 -0
  67. package/dist/types/modes/action-registry.d.ts +29 -0
  68. package/dist/types/modes/components/chrome.d.ts +14 -0
  69. package/dist/types/modes/components/command-palette.d.ts +27 -0
  70. package/dist/types/modes/components/custom-provider-wizard.d.ts +1 -0
  71. package/dist/types/modes/components/index.d.ts +2 -1
  72. package/dist/types/modes/components/pet-selector.d.ts +1 -1
  73. package/dist/types/modes/components/plan-preview-overlay.d.ts +43 -0
  74. package/dist/types/modes/components/queue-mode-selector.d.ts +1 -1
  75. package/dist/types/modes/components/queue-pane.d.ts +12 -0
  76. package/dist/types/modes/components/read-tool-group.d.ts +6 -0
  77. package/dist/types/modes/components/session-observer-overlay.d.ts +3 -4
  78. package/dist/types/modes/components/sessions-dashboard.d.ts +32 -0
  79. package/dist/types/modes/components/settings-selector.d.ts +1 -1
  80. package/dist/types/modes/components/show-images-selector.d.ts +1 -1
  81. package/dist/types/modes/components/status-line/types.d.ts +1 -1
  82. package/dist/types/modes/components/status-line.d.ts +1 -94
  83. package/dist/types/modes/components/tasks-pane.d.ts +15 -0
  84. package/dist/types/modes/components/theme-selector.d.ts +1 -1
  85. package/dist/types/modes/components/tool-execution.d.ts +15 -0
  86. package/dist/types/modes/components/tool-status-header.d.ts +111 -0
  87. package/dist/types/modes/components/transcript-viewer-overlay.d.ts +42 -0
  88. package/dist/types/modes/controllers/goal-mode-controller.d.ts +62 -0
  89. package/dist/types/modes/controllers/input-controller.d.ts +8 -1
  90. package/dist/types/modes/controllers/mode-gate.d.ts +8 -0
  91. package/dist/types/modes/controllers/plan-mode-controller.d.ts +65 -0
  92. package/dist/types/modes/controllers/selector-controller.d.ts +15 -3
  93. package/dist/types/modes/controllers/stt-controller.d.ts +26 -0
  94. package/dist/types/modes/interactive-mode.d.ts +19 -17
  95. package/dist/types/modes/jobs-observer.d.ts +2 -0
  96. package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +4 -11
  97. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +20 -2
  98. package/dist/types/modes/shared/agent-wire/workflow-gate-types.d.ts +31 -0
  99. package/dist/types/modes/tasks-aggregator.d.ts +39 -0
  100. package/dist/types/modes/transcript-item-registry.d.ts +60 -0
  101. package/dist/types/modes/types.d.ts +20 -13
  102. package/dist/types/modes/utils/ui-helpers.d.ts +2 -2
  103. package/dist/types/runtime-mcp/manager.d.ts +1 -1
  104. package/dist/types/sdk/broker/broker.d.ts +78 -12
  105. package/dist/types/sdk/broker/lifecycle-codec.d.ts +4 -0
  106. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +15 -1
  107. package/dist/types/sdk/broker/lifecycle.d.ts +6 -2
  108. package/dist/types/sdk/broker/process-incarnation.d.ts +2 -0
  109. package/dist/types/sdk/broker/session-index.d.ts +0 -1
  110. package/dist/types/sdk/broker/state-version.d.ts +2 -0
  111. package/dist/types/sdk/bus/chat-effect-journal.d.ts +1 -0
  112. package/dist/types/sdk/bus/config-commands.d.ts +1 -1
  113. package/dist/types/sdk/bus/config.d.ts +11 -0
  114. package/dist/types/sdk/bus/daemon-paths.d.ts +1 -0
  115. package/dist/types/sdk/bus/html-format.d.ts +3 -2
  116. package/dist/types/sdk/bus/index.d.ts +27 -0
  117. package/dist/types/sdk/bus/lifecycle-control-runtime.d.ts +4 -0
  118. package/dist/types/sdk/bus/notification-service.d.ts +20 -0
  119. package/dist/types/sdk/bus/rate-limit-pool.d.ts +13 -2
  120. package/dist/types/sdk/bus/recent-activity.d.ts +21 -12
  121. package/dist/types/sdk/bus/rich-document.d.ts +7 -0
  122. package/dist/types/sdk/bus/rich-render.d.ts +18 -41
  123. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +9 -4
  124. package/dist/types/sdk/bus/telegram-daemon.d.ts +46 -3
  125. package/dist/types/sdk/bus/threaded-inbound.d.ts +1 -1
  126. package/dist/types/sdk/bus/threaded-render.d.ts +13 -0
  127. package/dist/types/sdk/bus/topic-registry.d.ts +24 -2
  128. package/dist/types/sdk/host/control/operations.d.ts +1 -1
  129. package/dist/types/sdk/host/host.d.ts +2 -0
  130. package/dist/types/sdk/index.d.ts +1 -0
  131. package/dist/types/sdk/session-directory.d.ts +56 -0
  132. package/dist/types/session/agent-session.d.ts +82 -47
  133. package/dist/types/session/agent-storage.d.ts +7 -0
  134. package/dist/types/session/fallback-chain-controller.d.ts +10 -2
  135. package/dist/types/session/internal/managed-session-scope.d.ts +102 -0
  136. package/dist/types/session/internal/managed-session-storage.d.ts +38 -0
  137. package/dist/types/session/messages.d.ts +16 -0
  138. package/dist/types/session/session-manager-internal.d.ts +14 -0
  139. package/dist/types/session/session-manager.d.ts +82 -12
  140. package/dist/types/session/session-storage.d.ts +43 -3
  141. package/dist/types/session/volatile-context-pruning.d.ts +15 -0
  142. package/dist/types/skill-state/{deep-interview-mutation-guard.d.ts → workflow-mutation-guard.d.ts} +24 -8
  143. package/dist/types/skill-state/workflow-state-contract.d.ts +1 -3
  144. package/dist/types/slash-commands/builtin-registry.d.ts +4 -1
  145. package/dist/types/system-prompt.d.ts +2 -11
  146. package/dist/types/task/executor.d.ts +3 -1
  147. package/dist/types/thinking.d.ts +7 -0
  148. package/dist/types/tool-discovery/tool-index.d.ts +1 -50
  149. package/dist/types/tools/ask.d.ts +64 -2
  150. package/dist/types/tools/bash.d.ts +3 -0
  151. package/dist/types/tools/browser/dead-tab-recovery.d.ts +24 -0
  152. package/dist/types/tools/browser/tab-supervisor.d.ts +21 -0
  153. package/dist/types/tools/cron.d.ts +2 -0
  154. package/dist/types/tools/fetch.d.ts +1 -0
  155. package/dist/types/tools/index.d.ts +30 -15
  156. package/dist/types/tools/output-meta.d.ts +1 -0
  157. package/dist/types/tools/read.d.ts +3 -0
  158. package/dist/types/tools/search-tool-bm25.d.ts +3 -4
  159. package/dist/types/utils/event-bus.d.ts +1 -1
  160. package/dist/types/web/search/index.d.ts +3 -1
  161. package/package.json +21 -9
  162. package/scripts/build-sdk-package-smoke.ts +34 -1
  163. package/scripts/generate-hotkeys-docs.ts +74 -0
  164. package/scripts/generate-sdk-operation-inventory.ts +64 -20
  165. package/scripts/verify-gjc-sdk-canonicalization.ts +182 -2
  166. package/src/async/job-manager.ts +49 -6
  167. package/src/cli/config-cli.ts +75 -2
  168. package/src/cli/notify-cli.ts +92 -26
  169. package/src/cli/session-picker.ts +20 -3
  170. package/src/cli/setup-cli.ts +1 -1
  171. package/src/cli.ts +1 -1
  172. package/src/commands/config.ts +1 -1
  173. package/src/commands/harness.ts +34 -106
  174. package/src/commands/launch.ts +6 -4
  175. package/src/commands/mcp-serve.ts +85 -8
  176. package/src/commands/mcp.ts +2 -2
  177. package/src/commands/notify.ts +8 -4
  178. package/src/commands/sdk.ts +61 -40
  179. package/src/commands/ultragoal.ts +4 -1
  180. package/src/config/atomic-yaml-patch.ts +80 -13
  181. package/src/config/file-lock.ts +105 -43
  182. package/src/config/keybindings.ts +64 -20
  183. package/src/config/model-auth.ts +9 -0
  184. package/src/config/model-bindings-applier.ts +139 -0
  185. package/src/config/model-discovery-manager.ts +224 -0
  186. package/src/config/model-equivalence.ts +66 -0
  187. package/src/config/model-registry.ts +169 -302
  188. package/src/config/model-resolver.ts +361 -369
  189. package/src/config/models-config-schema.ts +5 -4
  190. package/src/config/settings-schema.ts +159 -2
  191. package/src/config/settings.ts +458 -482
  192. package/src/coordinator-mcp/policy.ts +76 -11
  193. package/src/coordinator-mcp/question-gate-codec.ts +274 -0
  194. package/src/coordinator-mcp/question-state.ts +778 -0
  195. package/src/coordinator-mcp/server.ts +1702 -238
  196. package/src/debug/index.ts +1 -1
  197. package/src/defaults/gjc/skills/deep-interview/SKILL.md +13 -1
  198. package/src/defaults/gjc/skills/team/SKILL.md +16 -0
  199. package/src/discovery/helpers.ts +1 -50
  200. package/src/edit/index.ts +64 -0
  201. package/src/eval/py/env.ts +26 -0
  202. package/src/eval/py/kernel.ts +5 -3
  203. package/src/extensibility/extensions/loader.ts +9 -1
  204. package/src/extensibility/extensions/runner.ts +12 -3
  205. package/src/extensibility/extensions/types.ts +39 -1
  206. package/src/extensibility/gjc-plugins/installer.ts +16 -16
  207. package/src/extensibility/hooks/runner.ts +2 -2
  208. package/src/extensibility/runtime-skill-discovery.ts +33 -13
  209. package/src/extensibility/shared-events.ts +2 -0
  210. package/src/gjc-runtime/deep-interview-recorder.ts +63 -3
  211. package/src/gjc-runtime/deep-interview-runtime.ts +76 -27
  212. package/src/gjc-runtime/deep-interview-state.ts +317 -5
  213. package/src/gjc-runtime/goal-mode-request.ts +55 -8
  214. package/src/gjc-runtime/launch-tmux.ts +2 -5
  215. package/src/gjc-runtime/launch-worktree.ts +5 -5
  216. package/src/gjc-runtime/linux-proc.ts +71 -0
  217. package/src/gjc-runtime/ralplan-runtime.ts +74 -90
  218. package/src/gjc-runtime/session-resolution.ts +18 -3
  219. package/src/gjc-runtime/session-state-sidecar.ts +6 -107
  220. package/src/gjc-runtime/state-migrations.ts +1 -1
  221. package/src/gjc-runtime/state-runtime.ts +320 -249
  222. package/src/gjc-runtime/state-writer.ts +36 -40
  223. package/src/gjc-runtime/team-launch.ts +319 -0
  224. package/src/gjc-runtime/team-notify.ts +326 -0
  225. package/src/gjc-runtime/team-runtime.ts +1099 -1482
  226. package/src/gjc-runtime/team-store.ts +828 -0
  227. package/src/gjc-runtime/team-workers.ts +962 -0
  228. package/src/gjc-runtime/tmux-owner-isolation-cli.ts +2 -11
  229. package/src/gjc-runtime/tmux-owner-isolation.ts +14 -9
  230. package/src/gjc-runtime/tmux-sessions.ts +4 -21
  231. package/src/gjc-runtime/ultragoal-change-set.ts +172 -0
  232. package/src/gjc-runtime/ultragoal-evidence.ts +874 -0
  233. package/src/gjc-runtime/ultragoal-guard.ts +29 -5
  234. package/src/gjc-runtime/ultragoal-pipeline.ts +306 -0
  235. package/src/gjc-runtime/ultragoal-runtime.ts +84 -1311
  236. package/src/gjc-runtime/workflow-cli-common.ts +30 -0
  237. package/src/gjc-runtime/workflow-manifest.generated.json +58 -0
  238. package/src/gjc-runtime/workflow-manifest.ts +18 -0
  239. package/src/goals/continuation-timeout-guard.ts +79 -0
  240. package/src/goals/runtime.ts +7 -5
  241. package/src/goals/state.ts +18 -0
  242. package/src/harness-control-plane/lifecycle-reconciliation.ts +64 -0
  243. package/src/harness-control-plane/phase-rollup.ts +4 -7
  244. package/src/harness-control-plane/receipt-ingest.ts +5 -0
  245. package/src/harness-control-plane/receipt-spool.ts +51 -7
  246. package/src/harness-control-plane/session-lease.ts +12 -130
  247. package/src/hindsight/backend.ts +26 -27
  248. package/src/hindsight/state.ts +101 -32
  249. package/src/hooks/skill-state.ts +17 -11
  250. package/src/internal-urls/docs-index.generated.ts +21 -18
  251. package/src/lsp/client.ts +1 -1
  252. package/src/lsp/config.ts +145 -26
  253. package/src/lsp/index.ts +1 -1
  254. package/src/lsp/lspmux.ts +39 -10
  255. package/src/lsp/path-trust.ts +81 -0
  256. package/src/main.ts +28 -8
  257. package/src/memories/index.ts +1 -0
  258. package/src/memories/storage.ts +6 -2
  259. package/src/modes/action-registry.ts +165 -0
  260. package/src/modes/components/chrome.ts +40 -0
  261. package/src/modes/components/command-palette.ts +168 -0
  262. package/src/modes/components/custom-editor.ts +1 -1
  263. package/src/modes/components/custom-provider-wizard.ts +51 -14
  264. package/src/modes/components/index.ts +2 -1
  265. package/src/modes/components/pet-selector.ts +11 -17
  266. package/src/modes/components/plan-preview-overlay.ts +295 -0
  267. package/src/modes/components/queue-mode-selector.ts +10 -27
  268. package/src/modes/components/queue-pane.ts +79 -0
  269. package/src/modes/components/read-tool-group.ts +13 -0
  270. package/src/modes/components/session-observer-overlay.ts +207 -772
  271. package/src/modes/components/sessions-dashboard.ts +181 -0
  272. package/src/modes/components/settings-selector.ts +1 -1
  273. package/src/modes/components/show-images-selector.ts +10 -24
  274. package/src/modes/components/status-line/types.ts +1 -1
  275. package/src/modes/components/status-line.ts +1 -969
  276. package/src/modes/components/tasks-pane.ts +73 -0
  277. package/src/modes/components/theme-selector.ts +11 -31
  278. package/src/modes/components/tool-execution.ts +22 -0
  279. package/src/modes/components/tool-status-header.ts +1056 -0
  280. package/src/modes/components/transcript-viewer-overlay.ts +384 -0
  281. package/src/modes/controllers/btw-controller.ts +1 -0
  282. package/src/modes/controllers/event-controller.ts +3 -2
  283. package/src/modes/controllers/extension-ui-controller.ts +24 -1
  284. package/src/modes/controllers/goal-mode-controller.ts +474 -0
  285. package/src/modes/controllers/input-controller.ts +764 -229
  286. package/src/modes/controllers/mode-gate.ts +24 -0
  287. package/src/modes/controllers/plan-mode-controller.ts +517 -0
  288. package/src/modes/controllers/selector-controller.ts +225 -10
  289. package/src/modes/controllers/stt-controller.ts +125 -0
  290. package/src/modes/interactive-mode.ts +289 -1150
  291. package/src/modes/jobs-observer.ts +3 -0
  292. package/src/modes/runtime-init.ts +8 -0
  293. package/src/modes/shared/agent-wire/deep-interview-gate.ts +31 -106
  294. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +71 -16
  295. package/src/modes/shared/agent-wire/workflow-gate-types.ts +177 -0
  296. package/src/modes/tasks-aggregator.ts +199 -0
  297. package/src/modes/transcript-item-registry.ts +229 -0
  298. package/src/modes/types.ts +45 -18
  299. package/src/modes/utils/ui-helpers.ts +17 -9
  300. package/src/prompts/agent-fragments/ralplan-persistence.md +6 -0
  301. package/src/prompts/agent-fragments/restricted-bash.md +1 -0
  302. package/src/prompts/agents/architect.md +2 -8
  303. package/src/prompts/agents/critic.md +2 -8
  304. package/src/prompts/agents/executor.md +3 -0
  305. package/src/prompts/agents/planner.md +4 -12
  306. package/src/prompts/system/custom-system-prompt.md +1 -23
  307. package/src/prompts/system/plan-mode-approved.md +8 -0
  308. package/src/prompts/system/system-prompt.md +17 -96
  309. package/src/prompts/tools/bash.md +4 -4
  310. package/src/prompts/tools/browser.md +15 -52
  311. package/src/prompts/tools/find.md +2 -2
  312. package/src/prompts/tools/hashline.md +13 -55
  313. package/src/prompts/tools/read.md +3 -6
  314. package/src/prompts/tools/search-tool-bm25.md +0 -2
  315. package/src/prompts/tools/search.md +2 -4
  316. package/src/prompts/tools/skill-discovery.md +1 -2
  317. package/src/prompts/tools/task.md +1 -1
  318. package/src/runtime-mcp/manager.ts +1 -1
  319. package/src/sdk/broker/broker.ts +206 -78
  320. package/src/sdk/broker/discovery.ts +1 -1
  321. package/src/sdk/broker/ensure.ts +3 -1
  322. package/src/sdk/broker/lifecycle-codec.ts +11 -0
  323. package/src/sdk/broker/lifecycle-ledger.ts +371 -74
  324. package/src/sdk/broker/lifecycle.ts +1399 -142
  325. package/src/sdk/broker/process-incarnation.ts +5 -13
  326. package/src/sdk/broker/session-index.ts +145 -56
  327. package/src/sdk/broker/state-version.ts +21 -0
  328. package/src/sdk/broker/transport.ts +1 -1
  329. package/src/sdk/bus/chat-daemon-cli.ts +2 -0
  330. package/src/sdk/bus/chat-daemon-control.ts +8 -28
  331. package/src/sdk/bus/chat-effect-journal.ts +10 -2
  332. package/src/sdk/bus/config-commands.ts +5 -4
  333. package/src/sdk/bus/config.ts +98 -0
  334. package/src/sdk/bus/conversation-store.ts +5 -29
  335. package/src/sdk/bus/daemon-paths.ts +2 -0
  336. package/src/sdk/bus/html-format.ts +20 -3
  337. package/src/sdk/bus/index.ts +778 -93
  338. package/src/sdk/bus/lifecycle-control-runtime.ts +91 -33
  339. package/src/sdk/bus/natives-augment.d.ts +26 -1
  340. package/src/sdk/bus/notification-service.ts +168 -20
  341. package/src/sdk/bus/rate-limit-pool.ts +58 -14
  342. package/src/sdk/bus/recent-activity.ts +188 -74
  343. package/src/sdk/bus/rich-document.ts +258 -0
  344. package/src/sdk/bus/rich-render.ts +62 -70
  345. package/src/sdk/bus/telegram-daemon-cli.ts +15 -59
  346. package/src/sdk/bus/telegram-daemon-contract.ts +9 -4
  347. package/src/sdk/bus/telegram-daemon.ts +1389 -237
  348. package/src/sdk/bus/threaded-inbound.ts +8 -3
  349. package/src/sdk/bus/threaded-render.ts +57 -1
  350. package/src/sdk/bus/topic-registry.ts +120 -13
  351. package/src/sdk/host/control/dispatch.ts +14 -2
  352. package/src/sdk/host/control/operations.ts +6 -1
  353. package/src/sdk/host/host.ts +31 -1
  354. package/src/sdk/host/query/handlers.ts +20 -22
  355. package/src/sdk/host/query/revision-store.ts +80 -57
  356. package/src/sdk/index.ts +1 -0
  357. package/src/sdk/protocol/operation-inventory.generated.json +943 -1907
  358. package/src/sdk/session-directory.ts +141 -0
  359. package/src/sdk/session.ts +458 -479
  360. package/src/session/agent-session.ts +1700 -583
  361. package/src/session/agent-storage.ts +18 -0
  362. package/src/session/cache-economics.ts +25 -8
  363. package/src/session/fallback-chain-controller.ts +53 -3
  364. package/src/session/internal/managed-session-scope.ts +2226 -0
  365. package/src/session/internal/managed-session-storage.ts +453 -0
  366. package/src/session/messages.ts +91 -2
  367. package/src/session/session-manager-internal.ts +33 -0
  368. package/src/session/session-manager.ts +1112 -333
  369. package/src/session/session-storage.ts +444 -39
  370. package/src/session/volatile-context-pruning.ts +78 -0
  371. package/src/setup/credential-auto-import.ts +2 -1
  372. package/src/setup/hermes/templates/operator-instructions.v1.md +1 -1
  373. package/src/setup/provider-onboarding.ts +36 -61
  374. package/src/setup/provider-presets.json +5 -0
  375. package/src/skill-state/active-state.ts +3 -13
  376. package/src/skill-state/{deep-interview-mutation-guard.ts → workflow-mutation-guard.ts} +47 -29
  377. package/src/skill-state/workflow-state-contract.ts +6 -41
  378. package/src/slash-commands/builtin-registry.ts +46 -11
  379. package/src/system-prompt.ts +37 -44
  380. package/src/task/agents.ts +19 -1
  381. package/src/task/executor.ts +90 -6
  382. package/src/task/index.ts +36 -21
  383. package/src/thinking.ts +21 -6
  384. package/src/tool-discovery/tool-index.ts +2 -134
  385. package/src/tools/ask.ts +157 -21
  386. package/src/tools/ast-edit.ts +1 -1
  387. package/src/tools/bash.ts +38 -0
  388. package/src/tools/browser/dead-tab-recovery.ts +78 -0
  389. package/src/tools/browser/tab-supervisor.ts +282 -36
  390. package/src/tools/computer.ts +10 -3
  391. package/src/tools/cron.ts +30 -16
  392. package/src/tools/eval.ts +1 -2
  393. package/src/tools/fetch.ts +11 -4
  394. package/src/tools/index.ts +76 -24
  395. package/src/tools/output-meta.ts +13 -5
  396. package/src/tools/read.ts +37 -1
  397. package/src/tools/resource-gc.ts +2 -2
  398. package/src/tools/search-tool-bm25.ts +11 -74
  399. package/src/utils/event-bus.ts +9 -4
  400. package/src/web/search/index.ts +3 -3
  401. package/dist/types/discovery/plugin-dir-roots.d.ts +0 -15
  402. package/dist/types/runtime-mcp/discoverable-tool-metadata.d.ts +0 -7
  403. package/src/discovery/plugin-dir-roots.ts +0 -28
  404. package/src/runtime-mcp/discoverable-tool-metadata.ts +0 -25
@@ -14,6 +14,7 @@ import type {
14
14
  TextContent,
15
15
  Usage,
16
16
  } from "@gajae-code/ai";
17
+ import * as native from "@gajae-code/natives";
17
18
  import { getTerminalId } from "@gajae-code/tui";
18
19
  import {
19
20
  getBlobsDir,
@@ -52,6 +53,16 @@ import {
52
53
  resolveResidentImageDataUrlSync,
53
54
  resolveTextBlobSync,
54
55
  } from "./blob-store";
56
+ import {
57
+ canonicalizeTrustedPath,
58
+ deleteManagedSessionCandidate,
59
+ listManagedCandidates,
60
+ type ManagedMigrationPolicy,
61
+ openManagedCandidateForWrite,
62
+ prepareManagedSessionScopeForWriteSync,
63
+ resolveManagedScope,
64
+ } from "./internal/managed-session-scope";
65
+
55
66
  import {
56
67
  type BashExecutionMessage,
57
68
  type CustomMessage,
@@ -64,6 +75,7 @@ import {
64
75
  sanitizeRehydratedOpenAIResponsesAssistantMessage,
65
76
  stripInternalDetailsFields,
66
77
  } from "./messages";
78
+ import { type SessionManagerReadAccess, sessionManagerReadCapability } from "./session-manager-internal";
67
79
  import type {
68
80
  SessionStorage,
69
81
  SessionStorageSnapshot,
@@ -75,7 +87,14 @@ import type {
75
87
  } from "./session-storage";
76
88
  import { FileSessionStorage, MemorySessionStorage } from "./session-storage";
77
89
 
78
- export const CURRENT_SESSION_VERSION = 3;
90
+ export const CURRENT_SESSION_VERSION = 4;
91
+
92
+ /**
93
+ * Version 4 adds append-only patch records. New writers emit v4 headers; v1–v3
94
+ * transcripts remain readable. Older builds do not apply these records, so they
95
+ * must not be used to edit a session after a v4 writer has updated it.
96
+ */
97
+
79
98
  function isUnderProjectGjc(cwd: string, targetPath: string): boolean {
80
99
  const relative = path.relative(path.join(path.resolve(cwd), ".gjc"), path.resolve(targetPath));
81
100
  return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
@@ -187,6 +206,15 @@ export function getSessionMessageViewportAnchorId(message: AgentMessage): string
187
206
  return sessionMessageViewportAnchorIds.get(message);
188
207
  }
189
208
 
209
+ /** Returns registered viewport anchors for durable user messages in session order. */
210
+ export function getUserMessageViewportAnchorIds(messages: readonly AgentMessage[]): string[] {
211
+ return messages.flatMap(message => {
212
+ if (message.role !== "user" || message.synthetic) return [];
213
+ const anchorId = getSessionMessageViewportAnchorId(message);
214
+ return anchorId ? [anchorId] : [];
215
+ });
216
+ }
217
+
190
218
  export function transferSessionMessageIdentity(source: AgentMessage[], target: AgentMessage[]): void {
191
219
  if (source.length !== target.length) {
192
220
  throw new Error(
@@ -365,7 +393,22 @@ export type SessionEntry =
365
393
  | ModeChangeEntry
366
394
  | ConfiguredModelChainEntry;
367
395
 
368
- /** Raw file entry (includes header) */
396
+ /** Append-only replacement for mutable fields on the session header. */
397
+ export interface HeaderPatchRecord {
398
+ type: "header_patch";
399
+ patch: Partial<Pick<SessionHeader, "title" | "titleSource" | "cwd">>;
400
+ }
401
+
402
+ /** Append-only replacement for replay metadata on one existing session entry. */
403
+ export interface EntryPatchRecord {
404
+ type: "entry_patch";
405
+ entryId: string;
406
+ patch: Partial<Pick<SessionMessageEntry, "message">>;
407
+ }
408
+
409
+ export type SessionPatchRecord = HeaderPatchRecord | EntryPatchRecord;
410
+
411
+ /** Resolved file entries; patch records are applied by parseSessionEntries(). */
369
412
  export type FileEntry = SessionHeader | SessionEntry;
370
413
 
371
414
  export type DefaultModelSelectionStage = {
@@ -445,11 +488,32 @@ export interface ResumeTailTerminal {
445
488
 
446
489
  export interface ResumeTailError {
447
490
  kind: "error";
448
- reason: "missing" | "malformed" | "unstable" | "read-failed";
491
+ reason: "missing" | "malformed" | "unstable" | "read-failed" | "legacy_migration_disabled";
449
492
  }
450
493
 
451
- export type ResumeTailInspection = ResumeTailResumable | ResumeTailTerminal | ResumeTailError;
494
+ export type SessionDirectoryMigrationPolicy = ManagedMigrationPolicy;
495
+
496
+ export class SessionManagedStorageError extends Error {
497
+ readonly code = "managed_storage_unsupported";
498
+
499
+ constructor() {
500
+ super(
501
+ "Default managed session storage requires FileSessionStorage; provide an explicit session directory for custom storage.",
502
+ );
503
+ this.name = "SessionManagedStorageError";
504
+ }
505
+ }
506
+
507
+ export class SessionMigrationPolicyError extends Error {
508
+ readonly code = "legacy_migration_disabled";
509
+
510
+ constructor() {
511
+ super("Legacy session migration is disabled for this workspace.");
512
+ this.name = "SessionMigrationPolicyError";
513
+ }
514
+ }
452
515
 
516
+ export type ResumeTailInspection = ResumeTailResumable | ResumeTailTerminal | ResumeTailError;
453
517
  export interface StrictSessionOpenSuccess {
454
518
  kind: "opened";
455
519
  manager: SessionManager;
@@ -488,6 +552,8 @@ export interface SessionInfo {
488
552
  created: Date;
489
553
  modified: Date;
490
554
  messageCount: number;
555
+ /** True when messageCount was counted from only the bounded list prefix. */
556
+ messageCountIsEstimate?: boolean;
491
557
  /** File size in bytes on disk; used for compact list rendering. */
492
558
  size: number;
493
559
  firstMessage: string;
@@ -544,30 +610,100 @@ export type SessionManagerCloseOutcome =
544
610
  | { kind: "close_failed_retryable"; error: Error }
545
611
  | { kind: "close_unknown"; error: Error };
546
612
 
547
- export type ReadonlySessionManager = Pick<
548
- SessionManager,
549
- | "getCwd"
550
- | "getSessionDir"
551
- | "getSessionId"
552
- | "getSessionFile"
553
- | "getSessionName"
554
- | "getArtifactsDir"
555
- | "getArtifactManager"
556
- | "allocateArtifactPath"
557
- | "saveArtifact"
558
- | "getArtifactPath"
559
- | "getLeafId"
560
- | "getLeafEntry"
561
- | "getEntry"
562
- | "getLabel"
563
- | "getBranch"
564
- | "getHeader"
565
- | "getEntries"
566
- | "getTree"
567
- | "getUsageStatistics"
568
- | "putBlob"
613
+ /** Read-only session state made available to extensions and custom tools. */
614
+ /** Frozen read-only session facade made available to extensions and custom tools. */
615
+ export interface ReadonlySessionManager {
616
+ getCwd(): string;
617
+ getSessionDir(): string;
618
+ getSessionId(): string;
619
+ getSessionFile(): string | undefined;
620
+ getSessionName(): string | undefined;
621
+ getArtifactsDir(): string | null;
622
+ getArtifactPath(id: string): Promise<string | null>;
623
+ getLeafId(): string | null;
624
+ getLeafEntry(): SessionEntry | undefined;
625
+ getEntry(id: string): SessionEntry | undefined;
626
+ getLabel(id: string): string | undefined;
627
+ getBranch(fromId?: string): SessionEntry[];
628
+ getHeader(): SessionHeader | null;
629
+ getEntries(): SessionEntry[];
630
+ getTree(): SessionTreeNode[];
631
+ getUsageStatistics(): UsageStatistics;
632
+ }
633
+
634
+ function cloneAndFreezeSnapshot<T>(value: T): T {
635
+ const cloned = cloneJsonSemantic(value);
636
+ const freeze = (candidate: unknown, seen = new WeakSet<object>()): void => {
637
+ if (candidate === null || typeof candidate !== "object" || seen.has(candidate)) return;
638
+ seen.add(candidate);
639
+ for (const child of Object.values(candidate)) freeze(child, seen);
640
+ Object.freeze(candidate);
641
+ };
642
+ freeze(cloned);
643
+ return cloned;
644
+ }
645
+
646
+ /** Creates an immutable facade that never exposes SessionManager mutation authority. */
647
+ export function createReadonlySessionManager(manager: SessionManager): ReadonlySessionManager {
648
+ const facade = Object.freeze({
649
+ getCwd: () => manager.getCwd(),
650
+ getSessionDir: () => manager.getSessionDir(),
651
+ getSessionId: () => manager.getSessionId(),
652
+ getSessionFile: () => manager.getSessionFile(),
653
+ getSessionName: () => manager.getSessionName(),
654
+ getArtifactsDir: () => manager.getArtifactsDir(),
655
+ getArtifactPath: (id: string) => manager.getArtifactPath(id),
656
+ getLeafId: () => manager.getLeafId(),
657
+ getLeafEntry: () => {
658
+ const entry = manager.getLeafEntry();
659
+ return entry === undefined ? undefined : cloneAndFreezeSnapshot(entry);
660
+ },
661
+ getEntry: (id: string) => {
662
+ const entry = manager.getEntry(id);
663
+ return entry === undefined ? undefined : cloneAndFreezeSnapshot(entry);
664
+ },
665
+ getLabel: (id: string) => manager.getLabel(id),
666
+ getBranch: (fromId?: string) => cloneAndFreezeSnapshot(manager.getBranch(fromId)),
667
+ getHeader: () => {
668
+ const header = manager.getHeader();
669
+ return header === null ? null : cloneAndFreezeSnapshot(header);
670
+ },
671
+ getEntries: () => cloneAndFreezeSnapshot(manager.getEntries()),
672
+ getTree: () => cloneAndFreezeSnapshot(manager.getTree()),
673
+ getUsageStatistics: () => cloneAndFreezeSnapshot(manager.getUsageStatistics()),
674
+ });
675
+ readonlySessionArtifactCapabilities.set(facade, sessionArtifactCapability(manager)!);
676
+ return facade;
677
+ }
678
+
679
+ /** Internal artifact-writing capability. Read-only facades expose it only through a private weak-map lookup. */
680
+ export type SessionArtifactCapability = Readonly<
681
+ Pick<SessionManager, "allocateArtifactPath" | "saveArtifact" | "putBlob">
569
682
  >;
570
683
 
684
+ const sessionArtifactCapabilities = new WeakMap<SessionManager, SessionArtifactCapability>();
685
+ const readonlySessionArtifactCapabilities = new WeakMap<ReadonlySessionManager, SessionArtifactCapability>();
686
+
687
+ /**
688
+ * Returns the artifact capability for a concrete persistence owner or one of its
689
+ * immutable read-only facades. Structural lookalikes remain unauthorized.
690
+ */
691
+ export function sessionArtifactCapability(value: unknown): SessionArtifactCapability | undefined {
692
+ if (value instanceof SessionManager) {
693
+ let capability = sessionArtifactCapabilities.get(value);
694
+ if (!capability) {
695
+ capability = Object.freeze({
696
+ allocateArtifactPath: value.allocateArtifactPath.bind(value),
697
+ saveArtifact: value.saveArtifact.bind(value),
698
+ putBlob: value.putBlob.bind(value),
699
+ });
700
+ sessionArtifactCapabilities.set(value, capability);
701
+ }
702
+ return capability;
703
+ }
704
+ if (value === null || typeof value !== "object") return undefined;
705
+ return readonlySessionArtifactCapabilities.get(value as ReadonlySessionManager);
706
+ }
571
707
  function createSessionId(): string {
572
708
  return Bun.randomUUIDv7();
573
709
  }
@@ -643,18 +779,17 @@ function migrateV2ToV3(entries: FileEntry[]): void {
643
779
  }
644
780
 
645
781
  /**
646
- * Run all necessary migrations to bring entries to current version.
647
- * Mutates entries in place. Returns true if any migration was applied.
782
+ * Run all necessary migrations to bring entries to the current semantic version.
783
+ * Version 4 adds trailing patch records, so v3 transcripts must be atomically upgraded before they can receive one.
648
784
  */
649
785
  function migrateToCurrentVersion(entries: FileEntry[]): boolean {
650
786
  const header = entries.find(e => e.type === "session") as SessionHeader | undefined;
651
787
  const version = header?.version ?? 1;
652
-
653
788
  if (version >= CURRENT_SESSION_VERSION) return false;
654
-
655
789
  if (version < 2) migrateV1ToV2(entries);
656
790
  if (version < 3) migrateV2ToV3(entries);
657
-
791
+ const migratedHeader = entries.find(entry => entry.type === "session") as SessionHeader | undefined;
792
+ if (migratedHeader) migratedHeader.version = CURRENT_SESSION_VERSION;
658
793
  return true;
659
794
  }
660
795
 
@@ -663,131 +798,75 @@ export function migrateSessionEntries(entries: FileEntry[]): void {
663
798
  migrateToCurrentVersion(entries);
664
799
  }
665
800
 
666
- const migratedSessionRoots = new Set<string>();
667
-
668
- /**
669
- * Merge or rename a legacy session directory into its canonical target.
670
- * Best effort: callers decide whether migration failures should surface.
671
- */
672
- function migrateSessionDirPath(oldPath: string, newPath: string): void {
673
- // Session-dir lifecycle migration: moves/removes whole directories, not file content writes.
674
- const existing = fs.statSync(newPath, { throwIfNoEntry: false });
675
- if (existing?.isDirectory()) {
676
- for (const file of fs.readdirSync(oldPath)) {
677
- const src = path.join(oldPath, file);
678
- const dst = path.join(newPath, file);
679
- if (!fs.existsSync(dst)) {
680
- fs.renameSync(src, dst);
681
- }
682
- }
683
- fs.rmSync(oldPath, { recursive: true, force: true });
684
- return;
685
- }
686
- if (existing) {
687
- fs.rmSync(newPath, { recursive: true, force: true });
688
- }
689
- fs.renameSync(oldPath, newPath);
690
- }
691
-
692
- function encodeLegacyAbsoluteSessionDirName(cwd: string): string {
693
- const resolvedCwd = path.resolve(cwd);
694
- return `--${resolvedCwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
695
- }
696
-
697
- function encodeRelativeSessionDirName(prefix: string, root: string, cwd: string): string {
698
- const relative = path.relative(root, cwd).replace(/[/\\:]/g, "-");
699
- return relative ? (prefix.endsWith("-") ? `${prefix}${relative}` : `${prefix}-${relative}`) : prefix;
700
- }
701
-
702
- function getDefaultSessionDirName(cwd: string): { encodedDirName: string; resolvedCwd: string } {
703
- const resolvedCwd = path.resolve(cwd);
704
- const canonicalCwd = resolveEquivalentPath(resolvedCwd);
705
- const home = resolveEquivalentPath(os.homedir());
706
- const tempRoot = resolveEquivalentPath(os.tmpdir());
707
- const encodedDirName = pathIsWithin(home, canonicalCwd)
708
- ? encodeRelativeSessionDirName("-", home, canonicalCwd)
709
- : pathIsWithin(tempRoot, canonicalCwd)
710
- ? encodeRelativeSessionDirName("-tmp", tempRoot, canonicalCwd)
711
- : encodeLegacyAbsoluteSessionDirName(canonicalCwd);
712
- return { encodedDirName, resolvedCwd };
713
- }
714
-
715
- /** Default and legacy directory candidates for maintenance-free resume discovery. */
716
- function getReadOnlyDefaultSessionDirs(cwd: string, sessionsRoot: string = getSessionsDir()): string[] {
717
- const { encodedDirName, resolvedCwd } = getDefaultSessionDirName(cwd);
718
- const canonicalCwd = resolveEquivalentPath(resolvedCwd);
719
- return [
720
- path.join(sessionsRoot, encodedDirName),
721
- path.join(sessionsRoot, encodeLegacyAbsoluteSessionDirName(canonicalCwd)),
722
- path.join(sessionsRoot, encodeLegacyAbsoluteSessionDirName(resolvedCwd)),
723
- ].filter((dir, index, dirs) => dirs.indexOf(dir) === index);
801
+ function resolveManagedSessionRoot(sessionDir: string, cwd: string): string | undefined {
802
+ const sessionsRoot = path.dirname(sessionDir);
803
+ const resolved = resolveManagedScope({ cwd, agentDir: path.resolve(sessionsRoot, ".."), sessionsRoot });
804
+ if (resolved.kind === "error") return undefined;
805
+ return path.resolve(sessionDir) === path.resolve(resolved.scope.directoryPath) ? sessionsRoot : undefined;
724
806
  }
725
807
 
726
- /**
727
- * Migrate old `--<home-encoded>-*--` session dirs to the new `-*` format.
728
- * Runs once per sessions root on first access, best-effort.
729
- */
730
- function migrateHomeSessionDirs(sessionsRoot: string): void {
731
- if (migratedSessionRoots.has(sessionsRoot)) return;
732
- migratedSessionRoots.add(sessionsRoot);
733
-
734
- const home = os.homedir();
735
- const homeEncoded = home.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-");
736
- const oldPrefix = `--${homeEncoded}-`;
737
- const oldExact = `--${homeEncoded}--`;
738
-
739
- let entries: string[];
740
- try {
741
- entries = fs.readdirSync(sessionsRoot);
742
- } catch {
743
- return;
744
- }
745
-
746
- for (const entry of entries) {
747
- let remainder: string;
748
- if (entry === oldExact) {
749
- remainder = "";
750
- } else if (entry.startsWith(oldPrefix) && entry.endsWith("--")) {
751
- remainder = entry.slice(oldPrefix.length, -2);
752
- } else {
808
+ /** Exported for compaction.test.ts */
809
+ export function parseSessionEntries(content: string): FileEntry[] {
810
+ const records = parseJsonlLenient<FileEntry | SessionPatchRecord>(content);
811
+ const entries: FileEntry[] = [];
812
+ const entriesById = new Map<string, SessionEntry>();
813
+ let header: SessionHeader | undefined;
814
+
815
+ for (const record of records) {
816
+ // Patch records are defined only by v4 transcripts. Ignore them before a
817
+ // v4 header as well: a truncated or malicious prefix must not mutate a
818
+ // later header/entry when replay catches up.
819
+ if (record.type === "header_patch") {
820
+ if (header?.version === CURRENT_SESSION_VERSION && isHeaderPatchRecord(record))
821
+ applyHeaderPatch(header, record.patch);
753
822
  continue;
754
823
  }
755
-
756
- const newName = remainder ? `-${remainder}` : "-";
757
- const oldPath = path.join(sessionsRoot, entry);
758
- const newPath = path.join(sessionsRoot, newName);
759
-
760
- try {
761
- migrateSessionDirPath(oldPath, newPath);
762
- } catch {
763
- // Best effort
824
+ if (record.type === "entry_patch") {
825
+ if (header?.version === CURRENT_SESSION_VERSION && isEntryPatchRecord(record)) {
826
+ const entry = entriesById.get(record.entryId);
827
+ if (entry?.type === "message" && record.patch.message) entry.message = record.patch.message;
828
+ }
829
+ continue;
764
830
  }
831
+ entries.push(record);
832
+ if (record.type === "session") header ??= record;
833
+ else entriesById.set(record.id, record);
765
834
  }
835
+ return entries;
766
836
  }
767
837
 
768
- function migrateLegacyAbsoluteSessionDir(cwd: string, sessionDir: string, sessionsRoot: string): void {
769
- const legacyDir = path.join(sessionsRoot, encodeLegacyAbsoluteSessionDirName(cwd));
770
- if (legacyDir === sessionDir || !fs.existsSync(legacyDir)) return;
771
-
772
- try {
773
- migrateSessionDirPath(legacyDir, sessionDir);
774
- } catch {
775
- // Best effort
776
- }
838
+ function isHeaderPatchRecord(record: SessionPatchRecord): record is HeaderPatchRecord {
839
+ if (
840
+ record.type !== "header_patch" ||
841
+ !isRecord(record.patch) ||
842
+ !Object.keys(record).every(key => key === "type" || key === "patch")
843
+ )
844
+ return false;
845
+ const keys = Object.keys(record.patch);
846
+ if (!keys.every(key => key === "cwd" || key === "title" || key === "titleSource")) return false;
847
+ const { cwd, title, titleSource } = record.patch;
848
+ return (
849
+ (cwd === undefined || typeof cwd === "string") &&
850
+ (title === undefined || typeof title === "string") &&
851
+ (titleSource === undefined || titleSource === "auto" || titleSource === "user")
852
+ );
777
853
  }
778
854
 
779
- function resolveManagedSessionRoot(sessionDir: string, cwd: string): string | undefined {
780
- const currentDirName = path.basename(sessionDir);
781
- const { encodedDirName } = getDefaultSessionDirName(cwd);
782
- if (currentDirName !== encodedDirName && currentDirName !== encodeLegacyAbsoluteSessionDirName(cwd)) {
783
- return undefined;
784
- }
785
- return path.dirname(sessionDir);
855
+ function applyHeaderPatch(header: SessionHeader, patch: HeaderPatchRecord["patch"]): void {
856
+ if (patch.cwd !== undefined) header.cwd = patch.cwd;
857
+ if (patch.title !== undefined) header.title = patch.title;
858
+ if (patch.titleSource !== undefined) header.titleSource = patch.titleSource;
786
859
  }
787
860
 
788
- /** Exported for compaction.test.ts */
789
- export function parseSessionEntries(content: string): FileEntry[] {
790
- return parseJsonlLenient<FileEntry>(content);
861
+ function isEntryPatchRecord(record: SessionPatchRecord): record is EntryPatchRecord {
862
+ return (
863
+ record.type === "entry_patch" &&
864
+ typeof record.entryId === "string" &&
865
+ isRecord(record.patch) &&
866
+ Object.keys(record).every(key => key === "type" || key === "entryId" || key === "patch") &&
867
+ Object.keys(record.patch).every(key => key === "message") &&
868
+ (record.patch.message === undefined || isRecord(record.patch.message))
869
+ );
791
870
  }
792
871
 
793
872
  function normalizeConfiguredModelChainEntry(entry: unknown): ConfiguredModelChain | undefined {
@@ -1126,22 +1205,30 @@ function cloneSessionContext(context: SessionContext): SessionContext {
1126
1205
  };
1127
1206
  }
1128
1207
 
1129
- /**
1130
- * Compute the default session directory for a cwd.
1131
- * Classifies cwd by canonical location so symlink/alias paths resolve to the
1132
- * same home-relative or temp-root directory names as their real targets.
1133
- */
1208
+ function freezeInternalReadSnapshot<T>(value: T): T {
1209
+ if (process.env.NODE_ENV !== "test" && process.env.NODE_ENV !== "development") return value;
1210
+ const copy = cloneJsonSemantic(value);
1211
+ const freeze = (item: unknown): void => {
1212
+ if (!item || typeof item !== "object" || Object.isFrozen(item)) return;
1213
+ for (const child of Object.values(item as Record<string, unknown>)) freeze(child);
1214
+ Object.freeze(item);
1215
+ };
1216
+ freeze(copy);
1217
+ return copy;
1218
+ }
1219
+
1220
+ /** Resolve and prepare the default v2 session scope before any managed writer exists. */
1134
1221
  function computeDefaultSessionDir(
1135
1222
  cwd: string,
1136
1223
  storage: SessionStorage,
1137
1224
  sessionsRoot: string = getSessionsDir(),
1138
1225
  ): string {
1139
- const { encodedDirName, resolvedCwd } = getDefaultSessionDirName(cwd);
1140
- migrateHomeSessionDirs(sessionsRoot);
1141
- const sessionDir = path.join(sessionsRoot, encodedDirName);
1142
- migrateLegacyAbsoluteSessionDir(resolvedCwd, sessionDir, sessionsRoot);
1143
- storage.ensureDirSync(sessionDir);
1144
- return sessionDir;
1226
+ if (!(storage instanceof FileSessionStorage)) throw new SessionManagedStorageError();
1227
+ const resolved = resolveManagedScope({ cwd, agentDir: path.resolve(sessionsRoot, ".."), sessionsRoot });
1228
+ if (resolved.kind === "error") throw new Error(`Could not resolve managed session scope: ${resolved.message}`);
1229
+ const prepared = prepareManagedSessionScopeForWriteSync(resolved.scope);
1230
+ if (prepared.kind === "error") throw new Error(`Could not prepare managed session scope: ${prepared.message}`);
1231
+ return prepared.scope.directoryPath;
1145
1232
  }
1146
1233
 
1147
1234
  // =============================================================================
@@ -1213,9 +1300,39 @@ async function readTerminalBreadcrumb(cwd: string): Promise<string | null> {
1213
1300
  return null;
1214
1301
  }
1215
1302
 
1216
- // Verify the session file still exists
1217
- const stat = fs.statSync(sessionFile, { throwIfNoEntry: false });
1218
- if (stat?.isFile()) return sessionFile;
1303
+ const inspected = inspectResumeSessionFile(sessionFile, new FileSessionStorage());
1304
+ if ("kind" in inspected) {
1305
+ if (inspected.reason !== "missing") return null;
1306
+ const sessionsRoot = getSessionsDir();
1307
+ const resolved = resolveManagedScope({
1308
+ cwd: breadcrumbCwd,
1309
+ agentDir: path.resolve(sessionsRoot, ".."),
1310
+ sessionsRoot,
1311
+ });
1312
+ if (resolved.kind !== "resolved") return null;
1313
+ const listing = listManagedCandidates(resolved.scope);
1314
+ if (listing.kind !== "complete") return null;
1315
+ const migrated = listing.owned.filter(
1316
+ candidate => path.basename(candidate.path) === path.basename(sessionFile),
1317
+ );
1318
+ return migrated.length === 1 ? migrated[0]!.path : null;
1319
+ }
1320
+ const header = inspected.entries[0] as SessionHeader;
1321
+ if (resolveEquivalentPath(header.cwd) !== resolveEquivalentPath(breadcrumbCwd)) return null;
1322
+ const sessionsRoot = getSessionsDir();
1323
+ const resolved = resolveManagedScope({
1324
+ cwd: header.cwd,
1325
+ agentDir: path.resolve(sessionsRoot, ".."),
1326
+ sessionsRoot,
1327
+ });
1328
+ if (resolved.kind !== "resolved") return null;
1329
+ const listing = listManagedCandidates(resolved.scope);
1330
+ if (listing.kind !== "complete") return null;
1331
+ const exact = listing.owned.find(candidate => path.resolve(candidate.path) === path.resolve(sessionFile));
1332
+ if (exact) return exact.path;
1333
+ const byIdentity = listing.owned.find(candidate => candidate.sessionId === header.id);
1334
+ if (byIdentity) return byIdentity.path;
1335
+ return pathIsWithin(sessionsRoot, path.resolve(sessionFile)) ? null : path.resolve(sessionFile);
1219
1336
  } catch (err) {
1220
1337
  if (!isEnoent(err)) logger.debug("Terminal breadcrumb read failed", { err });
1221
1338
  // Breadcrumb doesn't exist or is corrupt — fall through
@@ -1235,7 +1352,7 @@ export async function loadEntriesFromFile(
1235
1352
  if (isEnoent(err)) return [];
1236
1353
  throw err;
1237
1354
  }
1238
- const entries = parseJsonlLenient<FileEntry>(content);
1355
+ const entries = parseSessionEntries(content);
1239
1356
 
1240
1357
  // Validate session header
1241
1358
  if (entries.length === 0) return entries;
@@ -1684,11 +1801,17 @@ async function getSortedSessions(sessionDir: string, storage: SessionStorage): P
1684
1801
  await Promise.all(
1685
1802
  files.map(async (path: string) => {
1686
1803
  try {
1687
- const content = await storage.readTextPrefix(path, 4096);
1804
+ const buffer = Buffer.allocUnsafe(SESSION_LIST_PREFIX_BYTES);
1805
+ const content = await readSessionListPrefix(path, storage, buffer);
1688
1806
  const entries = parseJsonlLenient<Record<string, unknown>>(content);
1689
1807
  if (entries.length === 0) return;
1690
1808
  const header = entries[0] as Record<string, unknown>;
1691
1809
  if (header.type !== "session" || typeof header.id !== "string") return;
1810
+ if (header.version === CURRENT_SESSION_VERSION) {
1811
+ for (const patch of await readSessionListTrailingPatches(path, storage, buffer)) {
1812
+ applySessionListHeaderPatch(header as unknown as SessionListHeader, patch);
1813
+ }
1814
+ }
1692
1815
  const mtime = storage.statSync(path).mtimeMs;
1693
1816
  const firstPrompt = header.title ? undefined : extractFirstUserPrompt(entries);
1694
1817
  sessions.push(new RecentSessionInfo(path, mtime, header, firstPrompt));
@@ -1725,21 +1848,223 @@ function formatTimeAgo(date: Date): string {
1725
1848
  return date.toLocaleDateString();
1726
1849
  }
1727
1850
 
1728
- async function movePathAcrossDevicesSafe(source: string, destination: string): Promise<void> {
1851
+ async function syncPathAndParent(filePath: string): Promise<void> {
1852
+ const file = await fs.promises.open(filePath, "r");
1853
+ try {
1854
+ await file.sync();
1855
+ } finally {
1856
+ await file.close();
1857
+ }
1858
+ if (process.platform !== "win32") {
1859
+ const parent = await fs.promises.open(path.dirname(filePath), "r");
1860
+ try {
1861
+ await parent.sync();
1862
+ } finally {
1863
+ await parent.close();
1864
+ }
1865
+ }
1866
+ }
1867
+
1868
+ interface SessionMoveDirectoryHandle {
1869
+ sync(): Promise<void>;
1870
+ close(): Promise<void>;
1871
+ }
1872
+
1873
+ export async function syncSessionMoveDirectory(
1874
+ directory: string,
1875
+ platform: NodeJS.Platform = process.platform,
1876
+ openDirectory: (directory: string) => Promise<SessionMoveDirectoryHandle> = value => fs.promises.open(value, "r"),
1877
+ ): Promise<void> {
1878
+ if (platform === "win32") return;
1879
+ const handle = await openDirectory(directory);
1729
1880
  try {
1730
- await fs.promises.rename(source, destination);
1881
+ await handle.sync();
1882
+ } finally {
1883
+ await handle.close();
1884
+ }
1885
+ }
1886
+
1887
+ /** Durably flush every copied file and directory before an EXDEV source removal. */
1888
+ async function syncCopiedTree(treePath: string): Promise<void> {
1889
+ const stat = await fs.promises.lstat(treePath);
1890
+ if (stat.isSymbolicLink()) throw new Error("Refusing to fsync a copied symbolic link during a cross-device move");
1891
+ if (stat.isFile()) {
1892
+ const file = await fs.promises.open(treePath, "r");
1893
+ try {
1894
+ await file.sync();
1895
+ } finally {
1896
+ await file.close();
1897
+ }
1731
1898
  return;
1732
- } catch (error) {
1733
- if (!hasFsCode(error, "EXDEV")) throw error;
1734
1899
  }
1735
- const stat = await fs.promises.stat(source);
1736
- if (stat.isDirectory()) {
1737
- await fs.promises.cp(source, destination, { recursive: true, force: false, errorOnExist: true });
1738
- await fs.promises.rm(source, { recursive: true, force: false });
1900
+ if (!stat.isDirectory()) throw new Error("Copied cross-device move contains an unsupported filesystem entry");
1901
+ const directory = await fs.promises.opendir(treePath);
1902
+ try {
1903
+ for (;;) {
1904
+ const entry = await directory.read();
1905
+ if (entry === null) break;
1906
+ await syncCopiedTree(path.join(treePath, entry.name));
1907
+ }
1908
+ } finally {
1909
+ await directory.close();
1910
+ }
1911
+ if (process.platform !== "win32") {
1912
+ const handle = await fs.promises.open(treePath, "r");
1913
+ try {
1914
+ await handle.sync();
1915
+ } finally {
1916
+ await handle.close();
1917
+ }
1918
+ }
1919
+ }
1920
+
1921
+ type CrossDeviceTreeIdentity = string;
1922
+
1923
+ /** Captures content plus topology; a later capture must match before source removal. */
1924
+ async function captureCrossDeviceTreeIdentity(treePath: string): Promise<CrossDeviceTreeIdentity> {
1925
+ const before = await fs.promises.lstat(treePath, { bigint: true });
1926
+ if (before.isSymbolicLink()) throw new Error("Refusing to move a symbolic link across devices");
1927
+ if (before.isFile()) {
1928
+ const bytes = await fs.promises.readFile(treePath);
1929
+ const after = await fs.promises.lstat(treePath, { bigint: true });
1930
+ if (
1931
+ before.dev !== after.dev ||
1932
+ before.ino !== after.ino ||
1933
+ before.size !== after.size ||
1934
+ before.mtimeNs !== after.mtimeNs
1935
+ )
1936
+ throw new Error("Cross-device move source changed while its identity was captured");
1937
+ return `file:${before.dev}:${before.ino}:${before.size}:${before.mtimeNs}:${crypto.createHash("sha256").update(bytes).digest("hex")}`;
1938
+ }
1939
+ if (!before.isDirectory()) throw new Error("Cross-device move contains an unsupported filesystem entry");
1940
+ const directory = await fs.promises.opendir(treePath);
1941
+ const entries: string[] = [];
1942
+ try {
1943
+ for (;;) {
1944
+ const entry = await directory.read();
1945
+ if (entry === null) break;
1946
+ entries.push(entry.name);
1947
+ }
1948
+ } finally {
1949
+ await directory.close();
1950
+ }
1951
+ entries.sort();
1952
+ const children = await Promise.all(
1953
+ entries.map(
1954
+ async name => `${JSON.stringify(name)}=${await captureCrossDeviceTreeIdentity(path.join(treePath, name))}`,
1955
+ ),
1956
+ );
1957
+ const after = await fs.promises.lstat(treePath, { bigint: true });
1958
+ if (
1959
+ before.dev !== after.dev ||
1960
+ before.ino !== after.ino ||
1961
+ before.size !== after.size ||
1962
+ before.mtimeNs !== after.mtimeNs
1963
+ )
1964
+ throw new Error("Cross-device move source topology changed while its identity was captured");
1965
+ return `directory:${before.dev}:${before.ino}:${before.size}:${before.mtimeNs}:[${children.join(",")}]`;
1966
+ }
1967
+
1968
+ async function removeCrossDeviceSourceIfUnchanged(source: string, expected: CrossDeviceTreeIdentity): Promise<void> {
1969
+ if ((await captureCrossDeviceTreeIdentity(source)) !== expected)
1970
+ throw new Error("Cross-device move source changed before removal");
1971
+ const stat = await fs.promises.lstat(source);
1972
+ if (stat.isDirectory()) await fs.promises.rm(source, { recursive: true, force: false });
1973
+ else await fs.promises.unlink(source);
1974
+ }
1975
+
1976
+ /**
1977
+ * An EXDEV retirement must be driven by a native descriptor/handle-bound
1978
+ * snapshot-copy-verify transaction. Do not fall back to pathname copy/remove:
1979
+ * that can retire a replacement after a crash or concurrent rename.
1980
+ */
1981
+ class CrossDeviceMoveUnsupportedError extends Error {
1982
+ constructor(source: string, destination: string, cause?: unknown) {
1983
+ super(
1984
+ `Cross-device session move is unavailable for ${source} -> ${destination}: native atomic detach/copy/verify support is required.`,
1985
+ { cause },
1986
+ );
1987
+ this.name = "CrossDeviceMoveUnsupportedError";
1988
+ }
1989
+ }
1990
+
1991
+ class PathMoveCompletedWithSyncFailure extends Error {
1992
+ constructor(readonly cause: unknown) {
1993
+ super("Path move completed but final source-directory sync failed.", { cause });
1994
+ }
1995
+ }
1996
+
1997
+ async function movePathAcrossDevicesSafe(source: string, destination: string): Promise<void> {
1998
+ const sourceIdentity = await captureCrossDeviceTreeIdentity(source);
1999
+ const renamed = native.renameNoReplacePath(source, destination);
2000
+ if (renamed.ok) {
2001
+ try {
2002
+ if ((await captureCrossDeviceTreeIdentity(destination)) !== sourceIdentity)
2003
+ throw new Error("Atomic session rename did not preserve the captured source identity");
2004
+ await syncSessionMoveDirectory(path.dirname(destination));
2005
+ if (path.dirname(source) !== path.dirname(destination)) await syncSessionMoveDirectory(path.dirname(source));
2006
+ } catch (error) {
2007
+ throw new PathMoveCompletedWithSyncFailure(error);
2008
+ }
1739
2009
  return;
1740
2010
  }
1741
- await fs.promises.copyFile(source, destination, fs.constants.COPYFILE_EXCL);
1742
- await fs.promises.unlink(source);
2011
+ if (renamed.code === "quarantine_collision") {
2012
+ const error = new Error(`Session move destination already exists: ${destination}`) as NodeJS.ErrnoException;
2013
+ error.code = "EEXIST";
2014
+ throw error;
2015
+ }
2016
+ if (renamed.code !== "atomic_unavailable")
2017
+ throw new Error(`Atomic session rename failed: ${renamed.code ?? "unknown"}`);
2018
+ const sourceStat = await fs.promises.lstat(source);
2019
+ if (!sourceStat.isDirectory()) {
2020
+ let destinationCreated = false;
2021
+ let sourceRemoved = false;
2022
+ try {
2023
+ try {
2024
+ await fs.promises.link(source, destination);
2025
+ destinationCreated = true;
2026
+ } catch (error) {
2027
+ if (hasFsCode(error, "EXDEV")) throw new CrossDeviceMoveUnsupportedError(source, destination, error);
2028
+ if (
2029
+ !hasFsCode(error, "EPERM") &&
2030
+ !hasFsCode(error, "EACCES") &&
2031
+ !hasFsCode(error, "ENOTSUP") &&
2032
+ !hasFsCode(error, "EOPNOTSUPP")
2033
+ )
2034
+ throw error;
2035
+ await fs.promises.copyFile(source, destination, fs.constants.COPYFILE_EXCL);
2036
+ destinationCreated = true;
2037
+ }
2038
+ await syncPathAndParent(destination);
2039
+ await removeCrossDeviceSourceIfUnchanged(source, sourceIdentity);
2040
+ sourceRemoved = true;
2041
+ await syncSessionMoveDirectory(path.dirname(source));
2042
+ return;
2043
+ } catch (error) {
2044
+ if (!sourceRemoved && destinationCreated) await fs.promises.unlink(destination).catch(() => undefined);
2045
+ if (sourceRemoved && !(error instanceof PathMoveCompletedWithSyncFailure))
2046
+ throw new PathMoveCompletedWithSyncFailure(error);
2047
+ throw error;
2048
+ }
2049
+ }
2050
+
2051
+ const destinationParent = await fs.promises.lstat(path.dirname(destination));
2052
+ if (sourceStat.dev !== destinationParent.dev) throw new CrossDeviceMoveUnsupportedError(source, destination);
2053
+
2054
+ try {
2055
+ await fs.promises.cp(source, destination, { recursive: true, force: false, errorOnExist: true });
2056
+ await syncCopiedTree(destination);
2057
+ await removeCrossDeviceSourceIfUnchanged(source, sourceIdentity);
2058
+ try {
2059
+ await syncSessionMoveDirectory(path.dirname(source));
2060
+ } catch (error) {
2061
+ throw new PathMoveCompletedWithSyncFailure(error);
2062
+ }
2063
+ } catch (error) {
2064
+ if (!(error instanceof PathMoveCompletedWithSyncFailure))
2065
+ await fs.promises.rm(destination, { recursive: true, force: true }).catch(() => undefined);
2066
+ throw error;
2067
+ }
1743
2068
  }
1744
2069
 
1745
2070
  const MAX_PERSIST_CHARS = 500_000;
@@ -2091,19 +2416,12 @@ function cloneJsonSemantic<T>(value: T): T {
2091
2416
  return cloned as T;
2092
2417
  }
2093
2418
 
2094
- function cloneAgentMessage<T extends AgentMessage>(message: T): T {
2095
- const cloned = {
2096
- ...message,
2097
- ...("content" in message ? { content: cloneJsonSemantic(message.content) } : {}),
2098
- ...("providerPayload" in message ? { providerPayload: cloneJsonSemantic(message.providerPayload) } : {}),
2099
- } as T;
2100
- transferSessionMessageIdentity([message], [cloned]);
2101
- return cloned;
2102
- }
2103
-
2104
2419
  function cloneSessionEntry(entry: SessionEntry): SessionEntry {
2105
- if (entry.type !== "message") return { ...entry };
2106
- return { ...entry, message: cloneAgentMessage(entry.message) } as SessionEntry;
2420
+ const cloned = cloneJsonSemantic(entry);
2421
+ if (entry.type === "message" && cloned.type === "message") {
2422
+ transferSessionMessageIdentity([entry.message], [cloned.message]);
2423
+ }
2424
+ return cloned;
2107
2425
  }
2108
2426
 
2109
2427
  function materializeProviderVisibleEntrySync(entry: SessionEntry, stores: ResidentBlobStores): SessionEntry {
@@ -2725,7 +3043,7 @@ class NdjsonFileWriter {
2725
3043
  }
2726
3044
 
2727
3045
  /** Queue a write. Returns a promise so callers can await if needed. */
2728
- write(entry: FileEntry): Promise<void> {
3046
+ write(entry: FileEntry | SessionPatchRecord): Promise<void> {
2729
3047
  if (this.#closed || this.#closing) throw new Error("Writer closed");
2730
3048
  if (this.#error) throw this.#error;
2731
3049
  const line = `${JSON.stringify(entry)}\n`;
@@ -2741,7 +3059,7 @@ class NdjsonFileWriter {
2741
3059
  * queue. Use only when no concurrent async write is in flight (the session-manager
2742
3060
  * persist path enforces this via `#flushed`/`#needsFullRewriteOnNextPersist`).
2743
3061
  */
2744
- writeSync(entry: FileEntry): void {
3062
+ writeSync(entry: FileEntry | SessionPatchRecord): void {
2745
3063
  if (this.#closed || this.#closing) throw new Error("Writer closed");
2746
3064
  if (this.#error) throw this.#error;
2747
3065
  const line = `${JSON.stringify(entry)}\n`;
@@ -2936,6 +3254,14 @@ function extractTextFromContent(content: Message["content"]): string {
2936
3254
  }
2937
3255
 
2938
3256
  const SESSION_LIST_PREFIX_BYTES = 4096;
3257
+ const SESSION_LIST_TRAILING_PATCH_BYTES = 4096;
3258
+ // Hard cap on how many trailing bytes the listers may scan for header patches.
3259
+ // moveTo folds cwd patches into line 1 via a full atomic rewrite, so only a
3260
+ // recent title patch can live unfolded near EOF; anything buried deeper is
3261
+ // folded on the next natural full rewrite. Keeping this bounded preserves the
3262
+ // O(SESSION_LIST_PREFIX_BYTES) listing cost that SESSION_LIST_PREFIX_BYTES exists for.
3263
+ const SESSION_LIST_TRAILING_PATCH_SCAN_CAP = SESSION_LIST_TRAILING_PATCH_BYTES * 4;
3264
+ const SESSION_NAME_MAX_CHARS = 1_000;
2939
3265
  const SESSION_LIST_PARALLEL_THRESHOLD = 64;
2940
3266
  const SESSION_LIST_MAX_WORKERS = 16;
2941
3267
  const sessionListPrefixDecoder = new TextDecoder("utf-8", { fatal: false });
@@ -2955,6 +3281,69 @@ async function readSessionListPrefix(file: string, storage: SessionStorage, buff
2955
3281
  }
2956
3282
  }
2957
3283
 
3284
+ async function readSessionListTrailingPatches(
3285
+ file: string,
3286
+ storage: SessionStorage,
3287
+ buffer: Buffer,
3288
+ ): Promise<HeaderPatchRecord["patch"][]> {
3289
+ if (!(storage instanceof FileSessionStorage)) {
3290
+ const content = await storage.readText(file);
3291
+ const entries = parseSessionEntries(content);
3292
+ const header = entries[0] as SessionHeader | undefined;
3293
+ return header?.type === "session" ? [{ cwd: header.cwd, title: header.title }] : [];
3294
+ }
3295
+
3296
+ const size = storage.statSync(file).size;
3297
+ if (size <= SESSION_LIST_PREFIX_BYTES) return [];
3298
+ const latest: HeaderPatchRecord["patch"] = {};
3299
+ let position = size;
3300
+ const scanFloor = Math.max(0, size - SESSION_LIST_TRAILING_PATCH_SCAN_CAP);
3301
+ let trailingFragment = Buffer.alloc(0);
3302
+ const chunkSize = Math.min(buffer.byteLength, SESSION_LIST_TRAILING_PATCH_BYTES);
3303
+ const handle = await fs.promises.open(file, "r");
3304
+ try {
3305
+ while (position > scanFloor && (latest.cwd === undefined || latest.title === undefined)) {
3306
+ const start = Math.max(0, position - chunkSize);
3307
+ const length = position - start;
3308
+ const { bytesRead } = await handle.read(buffer, 0, length, start);
3309
+ const combined = Buffer.concat([buffer.subarray(0, bytesRead), trailingFragment]);
3310
+ let complete = combined;
3311
+ if (start > 0) {
3312
+ const firstNewline = combined.indexOf(0x0a);
3313
+ if (firstNewline === -1) {
3314
+ trailingFragment = combined;
3315
+ position = start;
3316
+ continue;
3317
+ }
3318
+ trailingFragment = combined.subarray(0, firstNewline);
3319
+ complete = combined.subarray(firstNewline + 1);
3320
+ }
3321
+ const lines = complete.toString("utf8").split("\n");
3322
+ for (let index = lines.length - 1; index >= 0; index--) {
3323
+ const line = lines[index];
3324
+ if (!line) continue;
3325
+ try {
3326
+ const record = JSON.parse(line) as SessionPatchRecord;
3327
+ if (!isHeaderPatchRecord(record)) continue;
3328
+ if (latest.cwd === undefined && typeof record.patch.cwd === "string") latest.cwd = record.patch.cwd;
3329
+ if (latest.title === undefined && typeof record.patch.title === "string")
3330
+ latest.title = record.patch.title;
3331
+ } catch {
3332
+ // Ignore malformed or partial records exactly as the canonical loader does.
3333
+ }
3334
+ }
3335
+ position = start;
3336
+ }
3337
+ return latest.cwd === undefined && latest.title === undefined ? [] : [latest];
3338
+ } finally {
3339
+ await handle.close();
3340
+ }
3341
+ }
3342
+
3343
+ function applySessionListHeaderPatch(header: SessionListHeader, patch: HeaderPatchRecord["patch"]): void {
3344
+ if (typeof patch.cwd === "string") header.cwd = patch.cwd;
3345
+ if (typeof patch.title === "string") header.title = patch.title;
3346
+ }
2958
3347
  function decodeJsonStringFragment(value: string): string {
2959
3348
  const safeValue = value.endsWith("\\") ? value.slice(0, -1) : value;
2960
3349
  try {
@@ -3038,6 +3427,8 @@ function extractFirstUserMessageFromPrefix(content: string): string | undefined
3038
3427
  interface SessionListHeader {
3039
3428
  type: "session";
3040
3429
  id: string;
3430
+ version?: number;
3431
+
3041
3432
  cwd?: string;
3042
3433
  title?: string;
3043
3434
  parentSession?: string;
@@ -3053,6 +3444,7 @@ function parseSessionListHeader(
3053
3444
  return {
3054
3445
  type: "session",
3055
3446
  id: parsedHeader.id,
3447
+ version: typeof parsedHeader.version === "number" ? parsedHeader.version : undefined,
3056
3448
  cwd: typeof parsedHeader.cwd === "string" ? parsedHeader.cwd : undefined,
3057
3449
  title: typeof parsedHeader.title === "string" ? parsedHeader.title : undefined,
3058
3450
  parentSession: typeof parsedHeader.parentSession === "string" ? parsedHeader.parentSession : undefined,
@@ -3070,6 +3462,7 @@ function parseSessionListHeader(
3070
3462
  return {
3071
3463
  type: "session",
3072
3464
  id,
3465
+ version: Number(extractStringProperty(firstLine, "version")) || undefined,
3073
3466
  cwd: extractStringProperty(firstLine, "cwd"),
3074
3467
  title: extractStringProperty(firstLine, "title"),
3075
3468
  parentSession: extractStringProperty(firstLine, "parentSession"),
@@ -3093,9 +3486,14 @@ async function collectSessionFromFile(
3093
3486
  ): Promise<SessionInfo | undefined> {
3094
3487
  try {
3095
3488
  const content = await readSessionListPrefix(file, storage, buffer);
3096
- const entries = parseJsonlLenient<Record<string, unknown>>(content);
3489
+ const entries = parseSessionEntries(content).map(entry => entry as unknown as Record<string, unknown>);
3097
3490
  const header = parseSessionListHeader(content, entries);
3098
3491
  if (!header) return undefined;
3492
+ if (header.version === CURRENT_SESSION_VERSION) {
3493
+ for (const patch of await readSessionListTrailingPatches(file, storage, buffer)) {
3494
+ applySessionListHeaderPatch(header, patch);
3495
+ }
3496
+ }
3099
3497
 
3100
3498
  let parsedMessageCount = 0;
3101
3499
  let firstMessage = "";
@@ -3138,6 +3536,7 @@ async function collectSessionFromFile(
3138
3536
  created: new Date(header.timestamp ?? ""),
3139
3537
  modified: stats.mtime,
3140
3538
  messageCount,
3539
+ messageCountIsEstimate: stats.size > SESSION_LIST_PREFIX_BYTES,
3141
3540
  size: stats.size,
3142
3541
  firstMessage: firstMessage || "(no messages)",
3143
3542
  allMessagesText: allMessages.length > 0 ? allMessages.join(" ") : firstMessage,
@@ -3271,6 +3670,7 @@ export class SessionManager {
3271
3670
  #persistChain: Promise<void> = Promise.resolve();
3272
3671
  #persistError: Error | undefined;
3273
3672
  #persistErrorReported = false;
3673
+
3274
3674
  #artifactManager: ArtifactManager | null = null;
3275
3675
  #artifactManagerSessionFile: string | null = null;
3276
3676
  // When set, take precedence over the lazily-derived per-session manager.
@@ -3293,7 +3693,7 @@ export class SessionManager {
3293
3693
  #replayMetadataRevision = 0;
3294
3694
  #materializedEntriesRevision = -1;
3295
3695
  #materializedEntriesCache: SessionEntry[] | undefined;
3296
- #sessionContextCache: WeakRef<SessionContext> | undefined;
3696
+ #sessionContextCache: SessionContext | undefined;
3297
3697
  #sessionContextEntryRevision = -1;
3298
3698
  #sessionContextLeafRevision = -1;
3299
3699
  #sessionContextReplayMetadataRevision = -1;
@@ -3307,6 +3707,11 @@ export class SessionManager {
3307
3707
  #getEntriesMaterializerCallCount = 0;
3308
3708
  #materializedEntriesCachePopulateCount = 0;
3309
3709
  #pathOnlyContextBuildCount = 0;
3710
+ #internalReadAccess: SessionManagerReadAccess = {
3711
+ getEntries: () => freezeInternalReadSnapshot(this.#getMaterializedEntriesInternal()),
3712
+ getSessionContext: () => this.#getSessionContextForRead(),
3713
+ getTree: () => this.#getTree(freezeInternalReadSnapshot(this.#getMaterializedEntriesInternal())),
3714
+ };
3310
3715
 
3311
3716
  private constructor(
3312
3717
  private cwd: string,
@@ -3318,6 +3723,13 @@ export class SessionManager {
3318
3723
  this.#residentImageBlobStore = this.#blobStore;
3319
3724
  if (persist && sessionDir) {
3320
3725
  this.storage.ensureDirSync(sessionDir);
3726
+ // Canonicalize the trusted session directory (single choke point for every
3727
+ // creation path: create/open/moveTo/fork/SDK) so benign ancestor symlinks
3728
+ // (e.g. macOS `/var -> /private/var`, a symlinked `$HOME`) are resolved to a
3729
+ // symlink-free root before the strict owner-only and reparse guards run.
3730
+ if (this.storage instanceof FileSessionStorage) {
3731
+ this.sessionDir = canonicalizeTrustedPath(sessionDir);
3732
+ }
3321
3733
  }
3322
3734
  // Note: call _initSession() or _initSessionFile() after construction
3323
3735
  }
@@ -3481,19 +3893,19 @@ export class SessionManager {
3481
3893
  }
3482
3894
 
3483
3895
  async #hydrateExistingSession(sessionFile: string, entries: FileEntry[], migrationApplied: boolean): Promise<void> {
3484
- const header = entries[0] as SessionHeader;
3485
- this.#sessionFile = path.resolve(sessionFile);
3896
+ const ownedEntries = structuredClone(entries) as FileEntry[];
3897
+ const header = ownedEntries[0] as SessionHeader;
3898
+ this.#sessionFile = this.storage instanceof FileSessionStorage ? path.resolve(sessionFile) : sessionFile;
3486
3899
  this.#sessionId = header.id;
3487
3900
  this.#sessionName = header.title;
3488
3901
  this.#titleSource = header.titleSource;
3489
3902
  this.#needsFullRewriteOnNextPersist = migrationApplied;
3490
- await resolveBlobRefsInEntries(entries, this.#blobStore);
3491
- this.#fileEntries = entries;
3903
+ await resolveBlobRefsInEntries(ownedEntries, this.#blobStore);
3904
+ this.#fileEntries = ownedEntries;
3492
3905
  this.#resetResidentTextBlobStore();
3493
3906
  this.#fileEntries = this.#fileEntries.map(entry =>
3494
3907
  prepareEntryForResidentSync(entry, this.#residentBlobStores()),
3495
3908
  );
3496
- this.sanitizeLoadedOpenAIResponsesReplayMetadata();
3497
3909
  this.#buildIndex();
3498
3910
  this.#bumpAllRevisions();
3499
3911
  this.#flushed = true;
@@ -3511,7 +3923,7 @@ export class SessionManager {
3511
3923
  await this.#closePersistWriter();
3512
3924
  this.#persistError = undefined;
3513
3925
  this.#persistErrorReported = false;
3514
- this.#sessionFile = path.resolve(sessionFile);
3926
+ this.#sessionFile = this.storage instanceof FileSessionStorage ? path.resolve(sessionFile) : sessionFile;
3515
3927
  writeTerminalBreadcrumb(this.cwd, this.#sessionFile);
3516
3928
  this.#fileEntries = await loadEntriesFromFile(this.#sessionFile, this.storage);
3517
3929
  if (this.#fileEntries.length > 0) {
@@ -3527,12 +3939,12 @@ export class SessionManager {
3527
3939
  this.#fileEntries = this.#fileEntries.map(entry =>
3528
3940
  prepareEntryForResidentSync(entry, this.#residentBlobStores()),
3529
3941
  );
3530
- this.sanitizeLoadedOpenAIResponsesReplayMetadata();
3531
3942
 
3532
3943
  this.#buildIndex();
3533
3944
  this.#bumpAllRevisions();
3534
3945
  this.#flushed = true;
3535
3946
  this.#ensuredOnDisk = true;
3947
+ await this.#sanitizeLoadedOpenAIResponsesReplayMetadataAndPersist();
3536
3948
  } else {
3537
3949
  const explicitPath = this.#sessionFile;
3538
3950
  this.#newSessionSync();
@@ -3554,11 +3966,35 @@ export class SessionManager {
3554
3966
  return sessionFile;
3555
3967
  }
3556
3968
 
3557
- /** Delete a session file and its artifacts. Drains the persist writer first to avoid EPERM on Windows. ENOENT is treated as success. */
3969
+ /** Tombstone and exact-delete managed transcripts, detaching the active transcript first. */
3558
3970
  async dropSession(sessionPath: string): Promise<void> {
3971
+ const requestedPath = path.resolve(sessionPath);
3972
+ if (this.#sessionFile && path.resolve(this.#sessionFile) === requestedPath) {
3973
+ await this.newSession();
3974
+ }
3559
3975
  await this.#closePersistWriter();
3976
+ const managedRoot = resolveManagedSessionRoot(this.sessionDir, this.cwd);
3560
3977
  try {
3561
- await this.storage.deleteSessionWithArtifacts(sessionPath);
3978
+ if (managedRoot) {
3979
+ if (!pathIsWithin(managedRoot, requestedPath))
3980
+ throw new Error("Managed session deletion is limited to this manager's configured session root.");
3981
+ const resolved = resolveManagedScope({
3982
+ cwd: this.cwd,
3983
+ agentDir: path.resolve(managedRoot, ".."),
3984
+ sessionsRoot: managedRoot,
3985
+ });
3986
+ if (resolved.kind !== "resolved")
3987
+ throw new Error("Managed session storage could not be resolved for this manager.");
3988
+ const listing = listManagedCandidates(resolved.scope);
3989
+ if (listing.kind !== "complete")
3990
+ throw new Error("Managed session storage could not be verified; refusing deletion.");
3991
+ const candidate = listing.owned.find(candidate => path.resolve(candidate.path) === requestedPath);
3992
+ if (!candidate) throw new Error("Managed session deletion requires exact logical authorization.");
3993
+ const deleted = await deleteManagedSessionCandidate(resolved.scope, candidate);
3994
+ if (deleted.kind === "error") throw new Error(`Could not delete managed session: ${deleted.message}`);
3995
+ } else {
3996
+ await this.storage.deleteSessionWithArtifacts(sessionPath);
3997
+ }
3562
3998
  } catch (err) {
3563
3999
  if (isEnoent(err)) return;
3564
4000
  throw err;
@@ -3628,11 +4064,19 @@ export class SessionManager {
3628
4064
  async moveTo(newCwd: string): Promise<void> {
3629
4065
  const resolvedCwd = path.resolve(newCwd);
3630
4066
  if (resolvedCwd === this.cwd) return;
3631
-
3632
- const managedSessionsRoot = resolveManagedSessionRoot(this.sessionDir, this.cwd);
3633
- const newSessionDir = managedSessionsRoot
3634
- ? computeDefaultSessionDir(resolvedCwd, this.storage, managedSessionsRoot)
3635
- : computeDefaultSessionDir(resolvedCwd, this.storage);
4067
+ const previousCwd = this.cwd;
4068
+ const previousSessionDir = this.sessionDir;
4069
+ const previousSessionFile = this.#sessionFile;
4070
+ const previousArtifactDir = previousSessionFile?.slice(0, -6);
4071
+
4072
+ const managedSessionsRoot =
4073
+ this.storage instanceof FileSessionStorage ? resolveManagedSessionRoot(this.sessionDir, this.cwd) : undefined;
4074
+ const newSessionDir =
4075
+ this.storage instanceof FileSessionStorage
4076
+ ? managedSessionsRoot
4077
+ ? computeDefaultSessionDir(resolvedCwd, this.storage, managedSessionsRoot)
4078
+ : computeDefaultSessionDir(resolvedCwd, this.storage)
4079
+ : this.sessionDir;
3636
4080
  let hadSessionFile = false;
3637
4081
 
3638
4082
  if (this.persist && this.#sessionFile) {
@@ -3674,15 +4118,25 @@ export class SessionManager {
3674
4118
  try {
3675
4119
  // Guard: session file may not exist yet (no assistant messages persisted)
3676
4120
  if (hadSessionFile) {
3677
- await movePathAcrossDevicesSafe(oldSessionFile, newSessionFile);
3678
- movedSessionFile = true;
4121
+ try {
4122
+ await movePathAcrossDevicesSafe(oldSessionFile, newSessionFile);
4123
+ movedSessionFile = true;
4124
+ } catch (error) {
4125
+ if (error instanceof PathMoveCompletedWithSyncFailure) movedSessionFile = true;
4126
+ throw error;
4127
+ }
3679
4128
  }
3680
4129
 
3681
4130
  try {
3682
4131
  const stat = await fs.promises.stat(oldArtifactDir);
3683
4132
  if (stat.isDirectory()) {
3684
- await movePathAcrossDevicesSafe(oldArtifactDir, newArtifactDir);
3685
- movedArtifactDir = true;
4133
+ try {
4134
+ await movePathAcrossDevicesSafe(oldArtifactDir, newArtifactDir);
4135
+ movedArtifactDir = true;
4136
+ } catch (error) {
4137
+ if (error instanceof PathMoveCompletedWithSyncFailure) movedArtifactDir = true;
4138
+ throw error;
4139
+ }
3686
4140
  }
3687
4141
  } catch (err) {
3688
4142
  if (!isEnoent(err)) throw err;
@@ -3690,7 +4144,7 @@ export class SessionManager {
3690
4144
  } catch (err) {
3691
4145
  if (movedArtifactDir) {
3692
4146
  try {
3693
- await fs.promises.rename(newArtifactDir, oldArtifactDir);
4147
+ await movePathAcrossDevicesSafe(newArtifactDir, oldArtifactDir);
3694
4148
  } catch (rollbackErr) {
3695
4149
  restoreResidentStateAndThrow(
3696
4150
  new Error(
@@ -3701,7 +4155,7 @@ export class SessionManager {
3701
4155
  }
3702
4156
  if (movedSessionFile) {
3703
4157
  try {
3704
- await fs.promises.rename(newSessionFile, oldSessionFile);
4158
+ await movePathAcrossDevicesSafe(newSessionFile, oldSessionFile);
3705
4159
  } catch (rollbackErr) {
3706
4160
  restoreResidentStateAndThrow(
3707
4161
  new Error(
@@ -3719,27 +4173,63 @@ export class SessionManager {
3719
4173
  this.#bumpAllRevisions();
3720
4174
  }
3721
4175
 
3722
- // Update cwd and sessionDir after the move succeeds.
4176
+ // Update cwd and sessionDir after the physical move succeeds, but roll the move back if metadata persistence fails.
3723
4177
  this.cwd = resolvedCwd;
3724
4178
  this.sessionDir = newSessionDir;
3725
4179
 
3726
- // Update the session header in fileEntries
3727
- const header = this.#fileEntries.find(e => e.type === "session") as SessionHeader | undefined;
3728
- if (header) {
3729
- header.cwd = resolvedCwd;
3730
- this.#headerExportRevision++;
3731
- }
3732
-
3733
- // Rewrite the session file at its new location with updated header.
3734
- // hadSessionFile: file existed before move → must rewrite to update cwd
3735
- // hasAssistant: assistant messages in memory but file missing → recreate from memory
3736
- // Neither true → fresh session, never written → preserve lazy-persist
3737
4180
  const hasAssistant = this.#fileEntries.some(e => e.type === "message" && e.message.role === "assistant");
3738
- if (this.persist && this.#sessionFile && (hadSessionFile || hasAssistant)) {
3739
- await this.#rewriteFile();
4181
+ try {
4182
+ if (this.persist && this.#sessionFile && hadSessionFile) {
4183
+ await this.#appendHeaderPatch({ cwd: resolvedCwd });
4184
+ await this.#rewriteFile();
4185
+ } else if (this.persist && this.#sessionFile && hasAssistant) {
4186
+ await this.#appendHeaderPatch({ cwd: resolvedCwd });
4187
+ await this.#rewriteFile();
4188
+ } else {
4189
+ await this.#appendHeaderPatch({ cwd: resolvedCwd });
4190
+ }
4191
+ } catch (error) {
4192
+ const failedSessionFile = this.#sessionFile;
4193
+ const failedArtifactDir = failedSessionFile?.slice(0, -6);
4194
+ try {
4195
+ await this.#closePersistWriter().catch(() => {});
4196
+ this.#persistChain = Promise.resolve();
4197
+ this.#persistError = undefined;
4198
+ this.#persistErrorReported = false;
4199
+ if (
4200
+ this.storage instanceof FileSessionStorage &&
4201
+ failedArtifactDir &&
4202
+ previousArtifactDir &&
4203
+ failedArtifactDir !== previousArtifactDir &&
4204
+ fs.existsSync(failedArtifactDir)
4205
+ ) {
4206
+ await movePathAcrossDevicesSafe(failedArtifactDir, previousArtifactDir);
4207
+ }
4208
+ if (
4209
+ this.storage instanceof FileSessionStorage &&
4210
+ failedSessionFile &&
4211
+ previousSessionFile &&
4212
+ failedSessionFile !== previousSessionFile &&
4213
+ fs.existsSync(failedSessionFile)
4214
+ ) {
4215
+ await movePathAcrossDevicesSafe(failedSessionFile, previousSessionFile);
4216
+ }
4217
+ this.#sessionFile = previousSessionFile;
4218
+ this.cwd = previousCwd;
4219
+ this.sessionDir = previousSessionDir;
4220
+ const header = this.#fileEntries.find(entry => entry.type === "session") as SessionHeader | undefined;
4221
+ if (header) applyHeaderPatch(header, { cwd: previousCwd });
4222
+ this.#headerExportRevision++;
4223
+ if (previousSessionFile && this.storage.existsSync(previousSessionFile)) await this.#rewriteFile();
4224
+ } catch (rollbackError) {
4225
+ throw new Error(`Failed to rollback session move: ${toError(rollbackError).message}`, {
4226
+ cause: toError(error),
4227
+ });
4228
+ }
4229
+ throw error;
3740
4230
  }
3741
4231
 
3742
- // Update terminal breadcrumb
4232
+ // Update terminal breadcrumb only after the durable cwd transition succeeds.
3743
4233
  if (this.#sessionFile) {
3744
4234
  writeTerminalBreadcrumb(resolvedCwd, this.#sessionFile);
3745
4235
  }
@@ -3777,6 +4267,9 @@ export class SessionManager {
3777
4267
  this.#usageStatistics = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, premiumRequests: 0, cost: 0 };
3778
4268
  this.#inMemoryArtifacts = null;
3779
4269
  this.#inMemoryArtifactCounter = 0;
4270
+ this.#artifactManager = null;
4271
+ this.#artifactManagerSessionFile = null;
4272
+ this.#adoptedArtifactManager = null;
3780
4273
 
3781
4274
  if (this.persist) {
3782
4275
  const fileTimestamp = timestamp.replace(/[:.]/g, "-");
@@ -4104,19 +4597,23 @@ export class SessionManager {
4104
4597
  }
4105
4598
  }
4106
4599
 
4107
- async #rewriteFile(): Promise<void> {
4600
+ async #rewriteFileContents(): Promise<void> {
4108
4601
  if (!this.persist || !this.#sessionFile) return;
4602
+ await this.#closePersistWriterInternal();
4603
+ const entries = await Promise.all(
4604
+ materializeResidentEntriesForPersistenceSync(this.#fileEntries, this.#residentBlobStores()).map(entry =>
4605
+ prepareEntryForPersistence(entry, this.#blobStore),
4606
+ ),
4607
+ );
4608
+ await this.#writeEntriesAtomically(entries);
4609
+ this.#needsFullRewriteOnNextPersist = false;
4610
+ this.#flushed = true;
4611
+ this.#ensuredOnDisk = true;
4612
+ }
4613
+
4614
+ async #rewriteFile(): Promise<void> {
4109
4615
  await this.#queuePersistTask(async () => {
4110
- await this.#closePersistWriterInternal();
4111
- const entries = await Promise.all(
4112
- materializeResidentEntriesForPersistenceSync(this.#fileEntries, this.#residentBlobStores()).map(entry =>
4113
- prepareEntryForPersistence(entry, this.#blobStore),
4114
- ),
4115
- );
4116
- await this.#writeEntriesAtomically(entries);
4117
- this.#needsFullRewriteOnNextPersist = false;
4118
- this.#flushed = true;
4119
- this.#ensuredOnDisk = true;
4616
+ await this.#rewriteFileContents();
4120
4617
  });
4121
4618
  }
4122
4619
 
@@ -4520,7 +5017,8 @@ export class SessionManager {
4520
5017
  return name
4521
5018
  .replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
4522
5019
  .replace(/ +/g, " ")
4523
- .trim();
5020
+ .trim()
5021
+ .slice(0, SESSION_NAME_MAX_CHARS);
4524
5022
  }
4525
5023
 
4526
5024
  /**
@@ -4537,23 +5035,40 @@ export class SessionManager {
4537
5035
 
4538
5036
  this.#sessionName = sanitized;
4539
5037
  this.#titleSource = source;
5038
+ await this.#appendHeaderPatch({ title: sanitized, titleSource: source });
5039
+ return true;
5040
+ }
4540
5041
 
4541
- // Update the in-memory header (so first flush includes title)
4542
- const header = this.#fileEntries.find(e => e.type === "session") as SessionHeader | undefined;
4543
- if (header) {
4544
- header.title = sanitized;
4545
- header.titleSource = source;
4546
- }
5042
+ async #appendHeaderPatch(patch: HeaderPatchRecord["patch"]): Promise<void> {
5043
+ const header = this.#fileEntries.find(entry => entry.type === "session") as SessionHeader | undefined;
5044
+ if (!header) return;
5045
+ applyHeaderPatch(header, patch);
4547
5046
  this.#headerExportRevision++;
4548
-
4549
- // Update the session file header with the title (if already flushed)
4550
- const sessionFile = this.#sessionFile;
4551
- if (this.persist && sessionFile && this.storage.existsSync(sessionFile)) {
4552
- await this.#rewriteFile();
4553
- }
4554
- return true;
5047
+ await this.#persistPatch({ type: "header_patch", patch });
4555
5048
  }
4556
5049
 
5050
+ async #persistPatch(record: SessionPatchRecord): Promise<void> {
5051
+ if (!this.persist || !this.#sessionFile || !this.storage.existsSync(this.#sessionFile)) return;
5052
+ await this.#queuePersistTask(async () => {
5053
+ const header = this.#fileEntries.find(entry => entry.type === "session") as SessionHeader | undefined;
5054
+ if (this.#needsFullRewriteOnNextPersist || !this.#flushed || (header?.version ?? 1) < CURRENT_SESSION_VERSION)
5055
+ return this.#rewriteFileContents();
5056
+ const writer = this.#ensurePersistWriter();
5057
+ if (!writer) return this.#rewriteFileContents();
5058
+ const persistedRecord =
5059
+ record.type === "entry_patch"
5060
+ ? (prepareEntryForPersistenceSync(
5061
+ materializeResidentEntryForPersistenceSync(
5062
+ record as unknown as FileEntry,
5063
+ this.#residentBlobStores(),
5064
+ new Map(),
5065
+ ),
5066
+ this.#blobStore,
5067
+ ) as unknown as SessionPatchRecord)
5068
+ : record;
5069
+ await writer.write(persistedRecord);
5070
+ });
5071
+ }
4557
5072
  _persist(entry: SessionEntry): void {
4558
5073
  if (!this.persist || !this.#sessionFile) return;
4559
5074
  if (this.#persistError) throw this.#persistError;
@@ -4566,6 +5081,7 @@ export class SessionManager {
4566
5081
  if (!hasAssistant) {
4567
5082
  // Mark as not flushed so when assistant arrives, all entries get written.
4568
5083
  this.#flushed = false;
5084
+
4569
5085
  this.#ensuredOnDisk = false;
4570
5086
  return;
4571
5087
  }
@@ -4859,6 +5375,22 @@ export class SessionManager {
4859
5375
  this.#replayMetadataRevision++;
4860
5376
  }
4861
5377
 
5378
+ /** Write mutated custom-message entries back into the canonical entry store by id. */
5379
+ applyCustomMessageEntryUpdates(entries: readonly CustomMessageEntry[]): void {
5380
+ for (const updated of entries) {
5381
+ const canonical = this.#byId.get(updated.id);
5382
+ if (canonical?.type !== "custom_message") continue;
5383
+ canonical.content = updated.content;
5384
+ canonical.details = updated.details;
5385
+ // Pruning replaces content permanently; retaining a cold-spill marker would
5386
+ // rehydrate the superseded payload on the next materialization.
5387
+ canonical.evictedContent = undefined;
5388
+ }
5389
+ this.#needsFullRewriteOnNextPersist = true;
5390
+ this.#bumpEntryRevision();
5391
+ this.#replayMetadataRevision++;
5392
+ }
5393
+
4862
5394
  /**
4863
5395
  * Rewrite the session file after in-place entry updates.
4864
5396
  * Use sparingly (e.g., pruning old tool outputs).
@@ -4978,7 +5510,9 @@ export class SessionManager {
4978
5510
  getLeafEntry(): SessionEntry | undefined {
4979
5511
  if (!this.#leafId) return undefined;
4980
5512
  const entry = this.#byId.get(this.#leafId);
4981
- return entry ? materializeResidentEntryForReadSync(entry, this.#residentBlobStores(), new Map()) : undefined;
5513
+ return entry
5514
+ ? cloneSessionEntry(materializeResidentEntryForReadSync(entry, this.#residentBlobStores(), new Map()))
5515
+ : undefined;
4982
5516
  }
4983
5517
 
4984
5518
  getResidentImageBytes(): number {
@@ -5163,10 +5697,12 @@ export class SessionManager {
5163
5697
  getEntryForFidelity(id: string): SessionEntry | undefined {
5164
5698
  const entry = this.#byId.get(id);
5165
5699
  return entry
5166
- ? rehydrateColdSpillEntry(
5167
- materializeResidentEntryForReadSync(entry, this.#residentBlobStores(), new Map()),
5168
- this.#blobStore,
5169
- this.#residentBlobStoresForColdRehydrate(),
5700
+ ? cloneSessionEntry(
5701
+ rehydrateColdSpillEntry(
5702
+ materializeResidentEntryForReadSync(entry, this.#residentBlobStores(), new Map()),
5703
+ this.#blobStore,
5704
+ this.#residentBlobStoresForColdRehydrate(),
5705
+ ),
5170
5706
  )
5171
5707
  : undefined;
5172
5708
  }
@@ -5180,10 +5716,12 @@ export class SessionManager {
5180
5716
  if (visited.has(current.id)) break;
5181
5717
  visited.add(current.id);
5182
5718
  path.push(
5183
- rehydrateColdSpillEntry(
5184
- materializeResidentEntryForReadSync(current, this.#residentBlobStores(), cache),
5185
- this.#blobStore,
5186
- this.#residentBlobStoresForColdRehydrate(),
5719
+ cloneSessionEntry(
5720
+ rehydrateColdSpillEntry(
5721
+ materializeResidentEntryForReadSync(current, this.#residentBlobStores(), cache),
5722
+ this.#blobStore,
5723
+ this.#residentBlobStoresForColdRehydrate(),
5724
+ ),
5187
5725
  ),
5188
5726
  );
5189
5727
  current = current.parentId ? this.#byId.get(current.parentId) : undefined;
@@ -5220,10 +5758,12 @@ export class SessionManager {
5220
5758
  return this.#fileEntries
5221
5759
  .filter((entry): entry is SessionEntry => entry.type !== "session")
5222
5760
  .map(entry =>
5223
- rehydrateColdSpillEntry(
5224
- materializeResidentEntryForReadSync(entry, this.#residentBlobStores(), cache),
5225
- this.#blobStore,
5226
- this.#residentBlobStoresForColdRehydrate(),
5761
+ cloneSessionEntry(
5762
+ rehydrateColdSpillEntry(
5763
+ materializeResidentEntryForReadSync(entry, this.#residentBlobStores(), cache),
5764
+ this.#blobStore,
5765
+ this.#residentBlobStoresForColdRehydrate(),
5766
+ ),
5227
5767
  ),
5228
5768
  );
5229
5769
  }
@@ -5232,7 +5772,9 @@ export class SessionManager {
5232
5772
  this.#publicMaterializerCallCount++;
5233
5773
  this.#getEntryMaterializerCallCount++;
5234
5774
  const entry = this.#byId.get(id);
5235
- return entry ? materializeResidentEntryForReadSync(entry, this.#residentBlobStores(), new Map()) : undefined;
5775
+ return entry
5776
+ ? cloneSessionEntry(materializeResidentEntryForReadSync(entry, this.#residentBlobStores(), new Map()))
5777
+ : undefined;
5236
5778
  }
5237
5779
 
5238
5780
  /**
@@ -5243,7 +5785,9 @@ export class SessionManager {
5243
5785
  const children: SessionEntry[] = [];
5244
5786
  for (const entry of this.#byId.values()) {
5245
5787
  if (entry.parentId === parentId) {
5246
- children.push(materializeResidentEntryForReadSync(entry, this.#residentBlobStores(), cache));
5788
+ children.push(
5789
+ cloneSessionEntry(materializeResidentEntryForReadSync(entry, this.#residentBlobStores(), cache)),
5790
+ );
5247
5791
  }
5248
5792
  }
5249
5793
  return children;
@@ -5298,7 +5842,7 @@ export class SessionManager {
5298
5842
  while (current) {
5299
5843
  if (visited.has(current.id)) break;
5300
5844
  visited.add(current.id);
5301
- path.push(materializeResidentEntryForReadSync(current, this.#residentBlobStores(), cache));
5845
+ path.push(cloneSessionEntry(materializeResidentEntryForReadSync(current, this.#residentBlobStores(), cache)));
5302
5846
  current = current.parentId ? this.#byId.get(current.parentId) : undefined;
5303
5847
  }
5304
5848
  path.reverse();
@@ -5309,15 +5853,26 @@ export class SessionManager {
5309
5853
  * Build the session context (what gets sent to the LLM).
5310
5854
  * Uses tree traversal from current leaf.
5311
5855
  */
5856
+ /**
5857
+ * Return a defensive context snapshot for public consumers.
5858
+ */
5312
5859
  buildSessionContext(): SessionContext {
5313
- const cached = this.#sessionContextCache?.deref();
5860
+ return cloneSessionContext(this.#getSessionContextForRead());
5861
+ }
5862
+
5863
+ /**
5864
+ * Return the revision-keyed context cache for internal read-only consumers.
5865
+ * The strong reference avoids GC-driven rebuilds during a session.
5866
+ */
5867
+ #getSessionContextForRead(): Readonly<SessionContext> {
5868
+ const cached = this.#sessionContextCache;
5314
5869
  if (
5315
5870
  cached &&
5316
5871
  this.#sessionContextEntryRevision === this.#entryRevision &&
5317
5872
  this.#sessionContextLeafRevision === this.#leafRevision &&
5318
5873
  this.#sessionContextReplayMetadataRevision === this.#replayMetadataRevision
5319
5874
  ) {
5320
- return cloneSessionContext(cached);
5875
+ return cached;
5321
5876
  }
5322
5877
  this.#pathOnlyContextBuildCount++;
5323
5878
  const context = buildSessionContext(
@@ -5326,11 +5881,12 @@ export class SessionManager {
5326
5881
  undefined,
5327
5882
  this.#sessionId,
5328
5883
  );
5329
- this.#sessionContextCache = new WeakRef(context);
5884
+ this.#sessionContextCache = freezeInternalReadSnapshot(context);
5885
+ transferSessionMessageIdentity(context.messages, this.#sessionContextCache.messages);
5330
5886
  this.#sessionContextEntryRevision = this.#entryRevision;
5331
5887
  this.#sessionContextLeafRevision = this.#leafRevision;
5332
5888
  this.#sessionContextReplayMetadataRevision = this.#replayMetadataRevision;
5333
- return cloneSessionContext(context);
5889
+ return this.#sessionContextCache;
5334
5890
  }
5335
5891
 
5336
5892
  #getActivePathEntriesForProviderContext(fromId?: string | null): SessionEntry[] {
@@ -5385,28 +5941,33 @@ export class SessionManager {
5385
5941
  const marker = entry.type === "message" || entry.type === "custom_message" ? entry.evictedContent : undefined;
5386
5942
  return marker ? Object.keys(marker.payloads ?? {}).length : 0;
5387
5943
  }
5388
- /** Strip stale OpenAI Responses assistant replay metadata from loaded in-memory entries. */
5944
+ /** Strip stale OpenAI Responses assistant replay metadata from loaded in-memory entries without persisting it. */
5389
5945
  sanitizeLoadedOpenAIResponsesReplayMetadata(): boolean {
5390
- let didSanitize = false;
5391
- for (const entry of this.#fileEntries) {
5392
- if (entry.type !== "message" || entry.message.role !== "assistant") {
5393
- continue;
5394
- }
5946
+ return this.#sanitizeLoadedOpenAIResponsesReplayMetadata().length > 0;
5947
+ }
5395
5948
 
5396
- const sanitizedMessage = sanitizeRehydratedOpenAIResponsesAssistantMessage(entry.message);
5397
- if (sanitizedMessage === entry.message) {
5398
- continue;
5399
- }
5949
+ async #sanitizeLoadedOpenAIResponsesReplayMetadataAndPersist(): Promise<boolean> {
5950
+ const patches = this.#sanitizeLoadedOpenAIResponsesReplayMetadata();
5951
+ for (const patch of patches) {
5952
+ await this.#persistPatch(patch);
5953
+ }
5954
+ return patches.length > 0;
5955
+ }
5400
5956
 
5957
+ #sanitizeLoadedOpenAIResponsesReplayMetadata(): EntryPatchRecord[] {
5958
+ const patches: EntryPatchRecord[] = [];
5959
+ for (const entry of this.#fileEntries) {
5960
+ if (entry.type !== "message" || entry.message.role !== "assistant") continue;
5961
+ const sanitizedMessage = sanitizeRehydratedOpenAIResponsesAssistantMessage(entry.message);
5962
+ if (sanitizedMessage === entry.message) continue;
5401
5963
  entry.message = sanitizedMessage;
5402
- didSanitize = true;
5964
+ patches.push({ type: "entry_patch", entryId: entry.id, patch: { message: sanitizedMessage } });
5403
5965
  }
5404
- if (didSanitize) {
5966
+ if (patches.length > 0) {
5405
5967
  this.#bumpEntryRevision();
5406
5968
  this.#replayMetadataRevision++;
5407
5969
  }
5408
-
5409
- return didSanitize;
5970
+ return patches;
5410
5971
  }
5411
5972
 
5412
5973
  /**
@@ -5418,9 +5979,7 @@ export class SessionManager {
5418
5979
  }
5419
5980
 
5420
5981
  /**
5421
- * Get all session entries (excludes header). Returns a shallow copy.
5422
- * The session is append-only: use appendXXX() to add entries, branch() to
5423
- * change the leaf pointer. Entries cannot be modified or deleted.
5982
+ * Internal materialized entry cache. Public getters clone this snapshot before returning it.
5424
5983
  */
5425
5984
  #getMaterializedEntriesInternal(): SessionEntry[] {
5426
5985
  if (this.#materializedEntriesRevision === this.#entryRevision && this.#materializedEntriesCache) {
@@ -5446,12 +6005,14 @@ export class SessionManager {
5446
6005
  }
5447
6006
 
5448
6007
  /**
5449
- * Get the session as a tree structure. Returns a shallow defensive copy of all entries.
6008
+ * Get the session as a tree structure. Returns defensive copies of all entries.
5450
6009
  * A well-formed session has exactly one root (first entry with parentId === null).
5451
6010
  * Orphaned entries (broken parent chain) are also returned as roots.
5452
6011
  */
5453
6012
  getTree(): SessionTreeNode[] {
5454
- const entries = this.getEntries();
6013
+ return this.#getTree(this.getEntries());
6014
+ }
6015
+ #getTree(entries: readonly SessionEntry[]): SessionTreeNode[] {
5455
6016
  const nodeMap = new Map<string, SessionTreeNode>();
5456
6017
  const roots: SessionTreeNode[] = [];
5457
6018
 
@@ -5523,7 +6084,9 @@ export class SessionManager {
5523
6084
 
5524
6085
  return roots;
5525
6086
  }
5526
-
6087
+ [sessionManagerReadCapability](): SessionManagerReadAccess {
6088
+ return this.#internalReadAccess;
6089
+ }
5527
6090
  // =========================================================================
5528
6091
  // Branching
5529
6092
  // =========================================================================
@@ -5700,9 +6263,16 @@ export class SessionManager {
5700
6263
 
5701
6264
  /** Resolve the default session directory without creating or migrating storage. */
5702
6265
  static getDefaultSessionDirReadOnly(cwd: string, agentDir?: string): string {
5703
- const { encodedDirName } = getDefaultSessionDirName(cwd);
5704
- return path.join(getSessionsDir(agentDir), encodedDirName);
6266
+ const sessionsRoot = getSessionsDir(agentDir);
6267
+ const resolved = resolveManagedScope({
6268
+ cwd,
6269
+ agentDir: agentDir ?? path.resolve(sessionsRoot, ".."),
6270
+ sessionsRoot,
6271
+ });
6272
+ if (resolved.kind === "error") throw new Error(`Could not resolve managed session scope: ${resolved.message}`);
6273
+ return resolved.scope.directoryPath;
5705
6274
  }
6275
+
5706
6276
  /**
5707
6277
  * Create a new session.
5708
6278
  * @param cwd Working directory (stored in session header)
@@ -5715,6 +6285,59 @@ export class SessionManager {
5715
6285
  return manager;
5716
6286
  }
5717
6287
 
6288
+ /** Resolve a default-managed candidate through binding validation and copy-retain migration before mutation. */
6289
+ static async prepareManagedCandidateForWrite(
6290
+ filePath: string,
6291
+ migrationPolicy: SessionDirectoryMigrationPolicy,
6292
+ ): Promise<string> {
6293
+ const storage = new FileSessionStorage();
6294
+ const inspected = inspectResumeSessionFile(filePath, storage);
6295
+ if ("kind" in inspected) {
6296
+ if (inspected.reason === "missing") return filePath;
6297
+ throw new Error(`Could not inspect managed session: ${inspected.reason}`);
6298
+ }
6299
+ const header = inspected.entries.find(entry => entry.type === "session") as SessionHeader | undefined;
6300
+ if (!header?.cwd) return filePath;
6301
+ const sessionsRoot = getSessionsDir();
6302
+ const resolvedPath = path.resolve(filePath);
6303
+ const relativeToManagedRoot = path.relative(sessionsRoot, resolvedPath);
6304
+ const isManagedPath =
6305
+ relativeToManagedRoot === "" ||
6306
+ (!relativeToManagedRoot.startsWith("..") && !path.isAbsolute(relativeToManagedRoot));
6307
+
6308
+ if (!isManagedPath) return filePath;
6309
+ const resolved = resolveManagedScope({
6310
+ cwd: header.cwd,
6311
+ agentDir: path.resolve(sessionsRoot, ".."),
6312
+ sessionsRoot,
6313
+ });
6314
+ if (resolved.kind === "error") throw new Error(`Could not resolve managed session scope: ${resolved.message}`);
6315
+
6316
+ const listing = listManagedCandidates(resolved.scope);
6317
+ if (listing.kind !== "complete") throw new Error(`Managed session scan failed: ${listing.message}`);
6318
+
6319
+ const candidate = listing.owned.find(item => path.resolve(item.path) === resolvedPath);
6320
+ if (!candidate) throw new Error("Session is inside managed storage but is not an authorized managed candidate.");
6321
+ const revalidated = inspectResumeSessionFile(filePath, storage);
6322
+ if (
6323
+ "kind" in revalidated ||
6324
+ !sameResumeIdentity(inspected.identity, revalidated.identity) ||
6325
+ candidate.identity.dev !== inspected.identity.dev ||
6326
+ candidate.identity.ino !== inspected.identity.ino ||
6327
+ candidate.identity.size !== inspected.identity.size ||
6328
+ candidate.identity.mtimeNs !== inspected.identity.mtimeNs ||
6329
+ candidate.identity.mtimeMs !== inspected.identity.mtimeMs ||
6330
+ candidate.identity.sha256 !== inspected.identity.sha256
6331
+ )
6332
+ throw new Error("Managed session changed before migration authority was adopted.");
6333
+ const opened = await openManagedCandidateForWrite(resolved.scope, candidate, migrationPolicy);
6334
+ if (opened.kind === "error") {
6335
+ if (opened.code === "legacy_migration_disabled") throw new SessionMigrationPolicyError();
6336
+ throw new Error(`Could not open managed session: ${opened.message}`);
6337
+ }
6338
+ return opened.path;
6339
+ }
6340
+
5718
6341
  /**
5719
6342
  * Fork a session into the current project directory.
5720
6343
  * Copies history from another session file while creating a new session file in the current sessionDir.
@@ -5724,10 +6347,15 @@ export class SessionManager {
5724
6347
  cwd: string,
5725
6348
  sessionDir?: string,
5726
6349
  storage: SessionStorage = new FileSessionStorage(),
6350
+ migrationPolicy: SessionDirectoryMigrationPolicy = "copy-retain",
5727
6351
  ): Promise<SessionManager> {
6352
+ const managedSourcePath =
6353
+ !sessionDir && storage instanceof FileSessionStorage
6354
+ ? await SessionManager.prepareManagedCandidateForWrite(sourcePath, migrationPolicy)
6355
+ : sourcePath;
5728
6356
  const dir = sessionDir ?? SessionManager.getDefaultSessionDir(cwd, undefined, storage);
5729
6357
  const manager = new SessionManager(cwd, dir, true, storage);
5730
- const forkEntries = structuredClone(await loadEntriesFromFile(sourcePath, storage)) as FileEntry[];
6358
+ const forkEntries = structuredClone(await loadEntriesFromFile(managedSourcePath, storage)) as FileEntry[];
5731
6359
  migrateToCurrentVersion(forkEntries);
5732
6360
  await resolveBlobRefsInEntries(forkEntries, manager.#blobStore);
5733
6361
  manager.#fileEntries = forkEntries;
@@ -5762,16 +6390,48 @@ export class SessionManager {
5762
6390
  filePath: string,
5763
6391
  sessionDir?: string,
5764
6392
  storage: SessionStorage = new FileSessionStorage(),
6393
+ migrationPolicy: SessionDirectoryMigrationPolicy = "copy-retain",
5765
6394
  ): Promise<SessionManager> {
5766
- // Extract cwd from session header if possible, otherwise use getProjectDir()
5767
- const entries = await loadEntriesFromFile(filePath, storage);
5768
- const header = entries.find(e => e.type === "session") as SessionHeader | undefined;
5769
- const cwd = header?.cwd ?? getProjectDir();
5770
- // If no sessionDir provided, derive from file's parent directory
5771
- const dir = sessionDir ?? path.resolve(filePath, "..");
5772
- const manager = new SessionManager(cwd, dir, true, storage);
5773
- await manager.#initSessionFile(filePath);
5774
- return manager;
6395
+ // Canonicalize benign ancestor symlinks in the caller-supplied path (e.g.
6396
+ // macOS `/var -> /private/var`, a symlinked `$HOME`/project) so explicit
6397
+ // session files land under a symlink-free trusted root that the strict
6398
+ // owner-only and reparse guards accept.
6399
+ if (storage instanceof FileSessionStorage) filePath = canonicalizeTrustedPath(filePath);
6400
+ if (sessionDir || !(storage instanceof FileSessionStorage)) {
6401
+ const entries = await loadEntriesFromFile(filePath, storage);
6402
+ const header = entries.find(entry => entry.type === "session") as SessionHeader | undefined;
6403
+ const manager = new SessionManager(
6404
+ header?.cwd ?? getProjectDir(),
6405
+ sessionDir ??
6406
+ (storage instanceof FileSessionStorage ? path.resolve(filePath, "..") : path.dirname(filePath)),
6407
+ true,
6408
+ storage,
6409
+ );
6410
+ await manager.#initSessionFile(filePath);
6411
+ return manager;
6412
+ }
6413
+
6414
+ const managedPath =
6415
+ !sessionDir && storage instanceof FileSessionStorage
6416
+ ? await SessionManager.prepareManagedCandidateForWrite(filePath, migrationPolicy)
6417
+ : filePath;
6418
+ const inspected = inspectResumeSessionFile(managedPath, storage);
6419
+ if ("kind" in inspected) {
6420
+ if (inspected.reason === "missing" && path.resolve(managedPath) === path.resolve(filePath)) {
6421
+ const manager = new SessionManager(getProjectDir(), path.resolve(filePath, ".."), true, storage);
6422
+ await manager.#initSessionFile(filePath);
6423
+ return manager;
6424
+ }
6425
+ throw new Error(`Could not open session: ${inspected.reason}`);
6426
+ }
6427
+ const opened = await SessionManager.openExistingStrict(
6428
+ inspected.identity,
6429
+ sessionDir ?? path.resolve(managedPath, ".."),
6430
+ storage,
6431
+ migrationPolicy,
6432
+ );
6433
+ if (opened.kind === "error") throw new Error(`Could not open session: ${opened.reason}`);
6434
+ return opened.manager;
5775
6435
  }
5776
6436
 
5777
6437
  /**
@@ -5782,16 +6442,60 @@ export class SessionManager {
5782
6442
  sessionDir?: string,
5783
6443
  storage: SessionStorage = new FileSessionStorage(),
5784
6444
  ): Promise<SessionInfo[]> {
5785
- const dirs = sessionDir ? [sessionDir] : getReadOnlyDefaultSessionDirs(cwd);
6445
+ if (!sessionDir && !(storage instanceof FileSessionStorage)) return [];
6446
+ if (sessionDir && !resolveManagedSessionRoot(sessionDir, cwd)) {
6447
+ try {
6448
+ return await collectSessionsFromFiles(storage.listFilesSync(sessionDir, "*.jsonl"), storage);
6449
+ } catch {
6450
+ return [];
6451
+ }
6452
+ }
6453
+ const sessionsRoot = sessionDir
6454
+ ? (resolveManagedSessionRoot(sessionDir, cwd) ?? getSessionsDir())
6455
+ : getSessionsDir();
6456
+ const resolved = resolveManagedScope({ cwd, agentDir: path.resolve(sessionsRoot, ".."), sessionsRoot });
6457
+ if (resolved.kind === "error") return [];
6458
+ if (storage instanceof FileSessionStorage) {
6459
+ const listing = listManagedCandidates(resolved.scope);
6460
+ if (listing.kind === "error") return [];
6461
+ return await collectSessionsFromFiles(
6462
+ listing.owned.map(candidate => candidate.path),
6463
+ storage,
6464
+ );
6465
+ }
6466
+ const legacyDirectory = `--${resolved.scope.canonicalCwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
6467
+ const files = new Set<string>();
5786
6468
  try {
5787
- const files = new Set<string>();
5788
- for (const dir of dirs) {
5789
- for (const filePath of storage.listFilesSync(dir, "*.jsonl")) files.add(filePath);
6469
+ for (const directory of [resolved.scope.directoryPath, path.join(sessionsRoot, legacyDirectory)]) {
6470
+ for (const file of storage.listFilesSync(directory, "*.jsonl")) files.add(file);
5790
6471
  }
5791
- return await collectSessionsFromFiles([...files], storage);
5792
6472
  } catch {
5793
6473
  return [];
5794
6474
  }
6475
+ return (await collectSessionsFromFiles([...files], storage)).filter(
6476
+ session => resolveEquivalentPath(session.cwd) === resolved.scope.canonicalCwd,
6477
+ );
6478
+ }
6479
+
6480
+ /** Tombstone and exact-delete a default-managed candidate. */
6481
+ static async deleteManagedCandidate(sessionPath: string): Promise<void> {
6482
+ const storage = new FileSessionStorage();
6483
+ const entries = await loadEntriesFromFile(sessionPath, storage);
6484
+ const header = entries.find(entry => entry.type === "session") as SessionHeader | undefined;
6485
+ if (!header?.cwd) throw new Error("Session has no valid managed workspace header.");
6486
+ const sessionsRoot = path.resolve(sessionPath, "../..");
6487
+ const resolved = resolveManagedScope({
6488
+ cwd: header.cwd,
6489
+ agentDir: path.resolve(sessionsRoot, ".."),
6490
+ sessionsRoot,
6491
+ });
6492
+ if (resolved.kind === "error") throw new Error(`Could not resolve managed session scope: ${resolved.message}`);
6493
+ const listing = listManagedCandidates(resolved.scope);
6494
+ if (listing.kind !== "complete") throw new Error("Managed session scan did not grant deletion authority.");
6495
+ const candidate = listing.owned.find(item => path.resolve(item.path) === path.resolve(sessionPath));
6496
+ if (!candidate) throw new Error("Session is not an authorized managed candidate.");
6497
+ const deleted = await deleteManagedSessionCandidate(resolved.scope, candidate);
6498
+ if (deleted.kind === "error") throw new Error(`Could not delete managed session: ${deleted.message}`);
5795
6499
  }
5796
6500
 
5797
6501
  /** Capture exact source content for a strict fork without granting write ownership. */
@@ -5821,6 +6525,7 @@ export class SessionManager {
5821
6525
  snapshot: CapturedSessionTranscriptSnapshot,
5822
6526
  cwd: string,
5823
6527
  sessionDir?: string,
6528
+ _migrationPolicy: SessionDirectoryMigrationPolicy = "copy-retain",
5824
6529
  ): Promise<StrictSessionForkResult> {
5825
6530
  if (crypto.createHash("sha256").update(snapshot.content).digest("hex") !== snapshot.identity.sha256) {
5826
6531
  return { kind: "error", reason: "identity-mismatch" };
@@ -5884,6 +6589,15 @@ export class SessionManager {
5884
6589
  throw new Error("Captured fork source authority changed before destination write.");
5885
6590
  }
5886
6591
  await manager.#rewriteFile();
6592
+ const afterWrite = inspectResumeSessionFile(snapshot.sourcePath, snapshot.storage);
6593
+ if ("kind" in afterWrite) {
6594
+ authorityFailure = afterWrite;
6595
+ throw new Error("Captured fork source authority changed during destination write.");
6596
+ }
6597
+ if (!sameResumeIdentity(snapshot.identity, afterWrite.identity)) {
6598
+ authorityFailure = { kind: "error", reason: "identity-mismatch" };
6599
+ throw new Error("Captured fork source authority changed during destination write.");
6600
+ }
5887
6601
  if (manager.#sessionFile) writeTerminalBreadcrumb(manager.cwd, manager.#sessionFile);
5888
6602
  return { kind: "forked", manager };
5889
6603
  } catch (error) {
@@ -5968,27 +6682,48 @@ export class SessionManager {
5968
6682
  identity: ResumeSessionIdentity,
5969
6683
  sessionDir?: string,
5970
6684
  storage: SessionStorage = new FileSessionStorage(),
6685
+ migrationPolicy: SessionDirectoryMigrationPolicy = "copy-retain",
5971
6686
  ): Promise<StrictSessionOpenResult> {
5972
- const inspected = inspectResumeSessionFile(identity.canonicalPath, storage);
6687
+ let sessionPath = identity.canonicalPath;
6688
+ if (!sessionDir && storage instanceof FileSessionStorage) {
6689
+ try {
6690
+ sessionPath = await SessionManager.prepareManagedCandidateForWrite(sessionPath, migrationPolicy);
6691
+ } catch (error) {
6692
+ if (error instanceof SessionMigrationPolicyError)
6693
+ return { kind: "error", reason: "legacy_migration_disabled" };
6694
+ throw error;
6695
+ }
6696
+ }
6697
+ const inspected = inspectResumeSessionFile(sessionPath, storage);
5973
6698
  if ("kind" in inspected) return inspected;
5974
- if (!sameResumeIdentity(identity, inspected.identity)) {
6699
+ if (
6700
+ (sessionPath === identity.canonicalPath && !sameResumeIdentity(identity, inspected.identity)) ||
6701
+ (sessionPath !== identity.canonicalPath &&
6702
+ (identity.sessionId !== inspected.identity.sessionId || identity.sha256 !== inspected.identity.sha256))
6703
+ ) {
5975
6704
  return { kind: "error", reason: "identity-mismatch" };
5976
6705
  }
5977
- const entries = structuredClone(inspected.entries) as FileEntry[];
6706
+ const entries = inspected.entries;
5978
6707
  const header = entries[0] as SessionHeader;
5979
- const dir = sessionDir ?? path.resolve(identity.canonicalPath, "..");
6708
+ const dir = sessionDir ?? path.resolve(sessionPath, "..");
5980
6709
  const manager = new SessionManager(header.cwd || getProjectDir(), dir, true, storage);
5981
- await manager.#hydrateExistingSession(identity.canonicalPath, entries, inspected.migrationApplied);
5982
- const ownershipInspection = inspectResumeSessionFile(identity.canonicalPath, storage);
6710
+ await manager.#hydrateExistingSession(sessionPath, entries, inspected.migrationApplied);
6711
+ const ownershipInspection = inspectResumeSessionFile(sessionPath, storage);
5983
6712
  if ("kind" in ownershipInspection) {
5984
6713
  await manager.close();
5985
6714
  return ownershipInspection;
5986
6715
  }
5987
- if (!sameResumeIdentity(identity, ownershipInspection.identity)) {
6716
+ if (!sameResumeIdentity(inspected.identity, ownershipInspection.identity)) {
5988
6717
  await manager.close();
5989
6718
  return { kind: "error", reason: "identity-mismatch" };
5990
6719
  }
5991
- writeTerminalBreadcrumb(manager.cwd, identity.canonicalPath);
6720
+ try {
6721
+ await manager.#sanitizeLoadedOpenAIResponsesReplayMetadataAndPersist();
6722
+ writeTerminalBreadcrumb(manager.cwd, sessionPath);
6723
+ } catch (error) {
6724
+ await manager.close();
6725
+ throw error;
6726
+ }
5992
6727
  return { kind: "opened", manager };
5993
6728
  }
5994
6729
 
@@ -6001,24 +6736,42 @@ export class SessionManager {
6001
6736
  cwd: string,
6002
6737
  sessionDir?: string,
6003
6738
  storage: SessionStorage = new FileSessionStorage(),
6739
+ migrationPolicy: SessionDirectoryMigrationPolicy = "copy-retain",
6004
6740
  ): Promise<SessionManager> {
6005
6741
  const dir = sessionDir ?? SessionManager.getDefaultSessionDir(cwd, undefined, storage);
6006
- // Prefer terminal-scoped breadcrumb (handles concurrent sessions correctly)
6742
+ const openSelectedStrictly = async (selectedPath: string): Promise<SessionManager | undefined> => {
6743
+ const inspected = await SessionManager.inspectSessionTailReadOnly(selectedPath, storage);
6744
+ if (inspected.kind === "error") return undefined;
6745
+ const opened = await SessionManager.openExistingStrict(
6746
+ inspected.identity,
6747
+ sessionDir,
6748
+ storage,
6749
+ migrationPolicy,
6750
+ );
6751
+ if (opened.kind === "error") {
6752
+ if (opened.reason === "legacy_migration_disabled") throw new SessionMigrationPolicyError();
6753
+ return undefined;
6754
+ }
6755
+ return opened.manager;
6756
+ };
6757
+ // Legacy paths are candidates only: the managed scan validates each transcript
6758
+ // header cwd before it can affect default resume authority.
6759
+ const managedCandidates = sessionDir
6760
+ ? []
6761
+ : await SessionManager.listForResumePickerReadOnly(cwd, undefined, storage);
6007
6762
  const terminalSession = await readTerminalBreadcrumb(cwd);
6008
- const mostRecent = terminalSession ?? (await findMostRecentSession(dir, storage));
6763
+ if (terminalSession) {
6764
+ const opened = await openSelectedStrictly(terminalSession);
6765
+ if (opened) return opened;
6766
+ }
6767
+ for (const candidate of managedCandidates) {
6768
+ const opened = await openSelectedStrictly(candidate.path);
6769
+ if (opened) return opened;
6770
+ }
6771
+ const mostRecent = await findMostRecentSession(dir, storage);
6009
6772
  if (mostRecent) {
6010
- // Adopt the resumed session's recorded cwd and its own directory. A
6011
- // `--worktree` session lives in a linked worktree whose path differs from
6012
- // the invocation cwd; binding the manager (and HUD) to `cwd` would leave
6013
- // it on the main checkout instead of the worktree it was created in.
6014
- const header = (await loadEntriesFromFile(mostRecent, storage)).find(e => e.type === "session") as
6015
- | SessionHeader
6016
- | undefined;
6017
- const resumeCwd = header?.cwd || cwd;
6018
- const resumeDir = sessionDir ?? path.resolve(mostRecent, "..");
6019
- const manager = new SessionManager(resumeCwd, resumeDir, true, storage);
6020
- await manager.#initSessionFile(mostRecent);
6021
- return manager;
6773
+ const opened = await openSelectedStrictly(mostRecent);
6774
+ if (opened) return opened;
6022
6775
  }
6023
6776
  const manager = new SessionManager(cwd, dir, true, storage);
6024
6777
  manager.#initNewSession();
@@ -6045,11 +6798,10 @@ export class SessionManager {
6045
6798
  sessionDir?: string,
6046
6799
  storage: SessionStorage = new FileSessionStorage(),
6047
6800
  ): Promise<SessionInfo[]> {
6048
- const dir = sessionDir ?? SessionManager.getDefaultSessionDir(cwd, undefined, storage);
6801
+ if (!sessionDir) return await SessionManager.listForResumePickerReadOnly(cwd, undefined, storage);
6049
6802
  try {
6050
- await recoverOrphanedBackups(dir, storage);
6051
- const files = storage.listFilesSync(dir, "*.jsonl");
6052
- return await collectSessionsFromFiles(files, storage);
6803
+ await recoverOrphanedBackups(sessionDir, storage);
6804
+ return await collectSessionsFromFiles(storage.listFilesSync(sessionDir, "*.jsonl"), storage);
6053
6805
  } catch {
6054
6806
  return [];
6055
6807
  }
@@ -6059,12 +6811,39 @@ export class SessionManager {
6059
6811
  * List all sessions across all project directories.
6060
6812
  */
6061
6813
  static async listAll(storage: SessionStorage = new FileSessionStorage()): Promise<SessionInfo[]> {
6814
+ if (!(storage instanceof FileSessionStorage)) return [];
6062
6815
  const sessionsRoot = path.join(getDefaultAgentDir(), "sessions");
6063
6816
  try {
6064
- const files = await Array.fromAsync(new Bun.Glob("*/*.jsonl").scan(sessionsRoot), name =>
6065
- path.join(sessionsRoot, name),
6066
- );
6067
- return await collectSessionsFromFiles(files, storage);
6817
+ const directories = await fs.promises.readdir(sessionsRoot, { withFileTypes: true });
6818
+ const seedFiles = directories
6819
+ .filter(entry => entry.isDirectory())
6820
+ .flatMap(entry => storage.listFilesSync(path.join(sessionsRoot, entry.name), "*.jsonl"));
6821
+ const seedSessions = await collectSessionsFromFiles(seedFiles, storage);
6822
+ const logicalFiles = new Set<string>();
6823
+ for (const cwd of new Set(seedSessions.map(session => session.cwd).filter(Boolean))) {
6824
+ const scope = resolveManagedScope({
6825
+ cwd,
6826
+ agentDir: path.resolve(sessionsRoot, ".."),
6827
+ sessionsRoot,
6828
+ });
6829
+ if (scope.kind !== "resolved") {
6830
+ logger.warn("Skipped invalid managed session scope during global listing", { message: scope.message });
6831
+ continue;
6832
+ }
6833
+ const listing = listManagedCandidates(scope.scope);
6834
+ if (listing.kind !== "complete") {
6835
+ logger.warn("Skipped unreadable managed session scope during global listing", {
6836
+ message: listing.message,
6837
+ });
6838
+ continue;
6839
+ }
6840
+ if (listing.invalid.length > 0)
6841
+ logger.warn("Ignored invalid managed session candidates during global listing", {
6842
+ count: listing.invalid.length,
6843
+ });
6844
+ for (const candidate of listing.owned) logicalFiles.add(candidate.path);
6845
+ }
6846
+ return await collectSessionsFromFiles([...logicalFiles], storage);
6068
6847
  } catch {
6069
6848
  return [];
6070
6849
  }