@cjhyy/code-shell-core 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (621) hide show
  1. package/README.md +36 -23
  2. package/dist/agent/agent-definition-registry.d.ts +2 -2
  3. package/dist/agent/agent-definition-registry.js +47 -10
  4. package/dist/agent/agent-definition.d.ts +5 -0
  5. package/dist/agent/agent-definition.js +14 -0
  6. package/dist/automation/index.d.ts +7 -4
  7. package/dist/automation/index.js +4 -2
  8. package/dist/automation/runner.js +3 -1
  9. package/dist/automation/scheduler.d.ts +57 -1
  10. package/dist/automation/scheduler.js +123 -23
  11. package/dist/automation/store.d.ts +6 -2
  12. package/dist/automation/store.js +7 -3
  13. package/dist/capabilities/index.d.ts +109 -0
  14. package/dist/capabilities/index.js +80 -0
  15. package/dist/capability-control/disabled-lists.d.ts +2 -1
  16. package/dist/capability-control/disabled-lists.js +3 -5
  17. package/dist/capability-control/overlay.d.ts +14 -1
  18. package/dist/capability-control/overlay.js +40 -0
  19. package/dist/capability-control/service.js +6 -4
  20. package/dist/cli/agent-server-stdio.d.ts +1 -1
  21. package/dist/cli/agent-server-stdio.js +66 -6
  22. package/dist/cli/agent-server-tcp.js +20 -5
  23. package/dist/context/compaction.d.ts +39 -3
  24. package/dist/context/compaction.js +151 -39
  25. package/dist/context/manager.d.ts +20 -4
  26. package/dist/context/manager.js +42 -7
  27. package/dist/context/token-counter.js +5 -3
  28. package/dist/context/tool-result-storage.d.ts +6 -0
  29. package/dist/context/tool-result-storage.js +25 -4
  30. package/dist/credentials/access.d.ts +13 -2
  31. package/dist/credentials/access.js +99 -4
  32. package/dist/credentials/index.d.ts +3 -2
  33. package/dist/credentials/index.js +2 -1
  34. package/dist/credentials/oauth.d.ts +9 -1
  35. package/dist/credentials/oauth.js +80 -1
  36. package/dist/credentials/store.d.ts +16 -2
  37. package/dist/credentials/store.js +33 -11
  38. package/dist/credentials/types.d.ts +62 -1
  39. package/dist/credentials/types.js +16 -1
  40. package/dist/credentials/use-credential-tool.js +15 -1
  41. package/dist/engine/auxiliary-pipeline.d.ts +40 -0
  42. package/dist/engine/auxiliary-pipeline.js +181 -0
  43. package/dist/{settings → engine}/disk-defaults.d.ts +2 -2
  44. package/dist/{settings → engine}/disk-defaults.js +5 -2
  45. package/dist/engine/dynamic-tool-defs.d.ts +4 -0
  46. package/dist/engine/dynamic-tool-defs.js +4 -0
  47. package/dist/engine/engine.d.ts +272 -184
  48. package/dist/engine/engine.js +1869 -1864
  49. package/dist/engine/file-history-hook.d.ts +11 -0
  50. package/dist/engine/file-history-hook.js +31 -0
  51. package/dist/engine/goal-judge-context.d.ts +9 -0
  52. package/dist/engine/goal-judge-context.js +234 -0
  53. package/dist/engine/injected-context-cache.d.ts +6 -0
  54. package/dist/engine/injected-context-cache.js +13 -0
  55. package/dist/engine/input-attachments.d.ts +1 -1
  56. package/dist/engine/model-facade.d.ts +3 -3
  57. package/dist/engine/model-facade.js +15 -14
  58. package/dist/engine/permission-controller.d.ts +55 -0
  59. package/dist/engine/permission-controller.js +148 -0
  60. package/dist/engine/prompt-cache-diagnostics.d.ts +58 -0
  61. package/dist/engine/prompt-cache-diagnostics.js +178 -0
  62. package/dist/engine/run-accounting.d.ts +41 -0
  63. package/dist/engine/run-accounting.js +126 -0
  64. package/dist/engine/run-context.d.ts +44 -0
  65. package/dist/engine/run-context.js +88 -0
  66. package/dist/engine/run-environment.d.ts +30 -0
  67. package/dist/engine/run-environment.js +77 -0
  68. package/dist/engine/run-finalize.d.ts +58 -0
  69. package/dist/engine/run-finalize.js +245 -0
  70. package/dist/engine/run-goal.d.ts +58 -0
  71. package/dist/engine/run-goal.js +188 -0
  72. package/dist/engine/run-image-input.d.ts +1 -1
  73. package/dist/engine/run-image-input.js +5 -3
  74. package/dist/engine/run-session-open.d.ts +41 -0
  75. package/dist/engine/run-session-open.js +186 -0
  76. package/dist/engine/run-setup.d.ts +42 -0
  77. package/dist/engine/run-setup.js +58 -0
  78. package/dist/engine/run-tooling.d.ts +125 -0
  79. package/dist/engine/run-tooling.js +283 -0
  80. package/dist/engine/run-types.d.ts +139 -0
  81. package/dist/engine/run-types.js +12 -0
  82. package/dist/engine/run-workspace.d.ts +50 -0
  83. package/dist/engine/run-workspace.js +114 -0
  84. package/dist/engine/session-title.d.ts +2 -1
  85. package/dist/engine/session-title.js +4 -1
  86. package/dist/engine/steer-queue.d.ts +1 -1
  87. package/dist/engine/subagent-spawner.d.ts +32 -0
  88. package/dist/engine/subagent-spawner.js +287 -0
  89. package/dist/engine/tool-summary.d.ts +2 -2
  90. package/dist/engine/tool-summary.js +2 -2
  91. package/dist/engine/turn-loop.d.ts +67 -10
  92. package/dist/engine/turn-loop.js +538 -97
  93. package/dist/engine/types.d.ts +35 -4
  94. package/dist/engine/types.js +1 -1
  95. package/dist/{engine/goal.d.ts → goal/lifecycle.d.ts} +80 -6
  96. package/dist/goal/lifecycle.js +482 -0
  97. package/dist/hooks/events.d.ts +26 -9
  98. package/dist/hooks/events.js +0 -3
  99. package/dist/hooks/goal-stop-hook.d.ts +28 -29
  100. package/dist/hooks/goal-stop-hook.js +344 -144
  101. package/dist/hooks/registry.js +4 -1
  102. package/dist/hooks/shell-runner.d.ts +12 -1
  103. package/dist/hooks/shell-runner.js +160 -9
  104. package/dist/index.d.ts +51 -110
  105. package/dist/index.extension.d.ts +51 -0
  106. package/dist/index.extension.js +39 -0
  107. package/dist/index.internal.d.ts +86 -0
  108. package/dist/index.internal.js +92 -0
  109. package/dist/index.js +37 -125
  110. package/dist/links/cli.d.ts +48 -0
  111. package/dist/links/cli.js +617 -0
  112. package/dist/links/http.d.ts +40 -0
  113. package/dist/links/http.js +166 -0
  114. package/dist/links/index.d.ts +4 -0
  115. package/dist/links/index.js +3 -0
  116. package/dist/links/link-action-tool.d.ts +5 -0
  117. package/dist/links/link-action-tool.js +241 -0
  118. package/dist/links/providers.d.ts +8 -0
  119. package/dist/links/providers.js +665 -0
  120. package/dist/links/types.d.ts +44 -0
  121. package/dist/llm/client-base.d.ts +7 -0
  122. package/dist/llm/client-base.js +72 -10
  123. package/dist/llm/model-pool.d.ts +1 -1
  124. package/dist/llm/model-pool.js +1 -1
  125. package/dist/llm/providers/anthropic.d.ts +1 -0
  126. package/dist/llm/providers/anthropic.js +9 -0
  127. package/dist/llm/providers/openai.d.ts +1 -0
  128. package/dist/llm/providers/openai.js +17 -0
  129. package/dist/llm/types.d.ts +12 -5
  130. package/dist/model-catalog/index.js +15 -2
  131. package/dist/model-catalog/resolve.d.ts +13 -1
  132. package/dist/model-catalog/resolve.js +37 -2
  133. package/dist/model-catalog/types.d.ts +74 -48
  134. package/dist/model-catalog/types.js +16 -5
  135. package/dist/model-catalog/upsert.d.ts +3 -1
  136. package/dist/model-catalog/upsert.js +9 -0
  137. package/dist/onboarding.d.ts +0 -1
  138. package/dist/onboarding.js +1 -22
  139. package/dist/panel-apps/bindings.d.ts +24 -0
  140. package/dist/panel-apps/bindings.js +69 -0
  141. package/dist/panel-apps/github-archive.d.ts +21 -0
  142. package/dist/panel-apps/github-archive.js +98 -0
  143. package/dist/panel-apps/index.d.ts +4 -0
  144. package/dist/panel-apps/index.js +4 -0
  145. package/dist/panel-apps/installer.d.ts +68 -0
  146. package/dist/panel-apps/installer.js +622 -0
  147. package/dist/panel-apps/manifest.d.ts +364 -0
  148. package/dist/panel-apps/manifest.js +189 -0
  149. package/dist/panel-apps/paths.d.ts +13 -0
  150. package/dist/panel-apps/paths.js +38 -0
  151. package/dist/panel-apps/registry.d.ts +51 -0
  152. package/dist/panel-apps/registry.js +99 -0
  153. package/dist/panel-apps/runtime.d.ts +80 -0
  154. package/dist/panel-apps/runtime.js +154 -0
  155. package/dist/plugins/gitOps.js +8 -1
  156. package/dist/plugins/installedPlugins.d.ts +2 -1
  157. package/dist/plugins/installedPlugins.js +146 -8
  158. package/dist/plugins/installer/codex/convertAgents.d.ts +4 -1
  159. package/dist/plugins/installer/codex/convertAgents.js +62 -8
  160. package/dist/plugins/installer/codex/convertCommands.d.ts +5 -5
  161. package/dist/plugins/installer/codex/convertCommands.js +35 -12
  162. package/dist/plugins/installer/codex/convertHooks.d.ts +10 -0
  163. package/dist/plugins/installer/codex/convertHooks.js +82 -0
  164. package/dist/plugins/installer/codex/convertSkills.js +41 -6
  165. package/dist/plugins/installer/install.d.ts +7 -1
  166. package/dist/plugins/installer/install.js +21 -87
  167. package/dist/plugins/installer/installFromArchive.d.ts +26 -4
  168. package/dist/plugins/installer/installFromArchive.js +44 -27
  169. package/dist/plugins/installer/installFromNpm.d.ts +31 -0
  170. package/dist/plugins/installer/installFromNpm.js +273 -0
  171. package/dist/plugins/installer/loadPluginAgents.js +17 -4
  172. package/dist/plugins/installer/loadPluginMcp.d.ts +14 -0
  173. package/dist/plugins/installer/loadPluginMcp.js +245 -35
  174. package/dist/plugins/installer/normalizeManifest.d.ts +13 -0
  175. package/dist/plugins/installer/normalizeManifest.js +306 -0
  176. package/dist/plugins/installer/npmTar.d.ts +13 -0
  177. package/dist/plugins/installer/npmTar.js +321 -0
  178. package/dist/plugins/installer/parseSource.d.ts +11 -0
  179. package/dist/plugins/installer/parseSource.js +48 -0
  180. package/dist/plugins/installer/preview.d.ts +101 -0
  181. package/dist/plugins/installer/preview.js +336 -0
  182. package/dist/plugins/installer/projectPluginSource.d.ts +14 -0
  183. package/dist/plugins/installer/projectPluginSource.js +140 -0
  184. package/dist/plugins/installer/types.d.ts +775 -0
  185. package/dist/plugins/installer/types.js +100 -0
  186. package/dist/plugins/installer/unzip.d.ts +11 -0
  187. package/dist/plugins/installer/unzip.js +123 -9
  188. package/dist/plugins/installer/update.d.ts +2 -2
  189. package/dist/plugins/installer/update.js +43 -4
  190. package/dist/plugins/loadPluginHooks.d.ts +19 -2
  191. package/dist/plugins/loadPluginHooks.js +97 -86
  192. package/dist/plugins/pluginAutomationTemplates.d.ts +18 -0
  193. package/dist/plugins/pluginAutomationTemplates.js +50 -0
  194. package/dist/plugins/pluginCatalog.d.ts +45 -0
  195. package/dist/plugins/pluginCatalog.js +87 -0
  196. package/dist/plugins/pluginCommandHook.d.ts +10 -4
  197. package/dist/plugins/pluginCommandHook.js +133 -18
  198. package/dist/plugins/pluginCommandsLoader.d.ts +19 -0
  199. package/dist/plugins/pluginCommandsLoader.js +181 -7
  200. package/dist/plugins/pluginContent.d.ts +10 -1
  201. package/dist/plugins/pluginContent.js +87 -30
  202. package/dist/plugins/pluginHookApproval.d.ts +30 -0
  203. package/dist/plugins/pluginHookApproval.js +160 -0
  204. package/dist/plugins/pluginHookIntegrity.d.ts +69 -0
  205. package/dist/plugins/pluginHookIntegrity.js +361 -0
  206. package/dist/plugins/pluginInstaller.js +71 -12
  207. package/dist/plugins/pluginMcpApproval.d.ts +19 -0
  208. package/dist/plugins/pluginMcpApproval.js +110 -0
  209. package/dist/plugins/pluginMcpIntegrity.d.ts +21 -0
  210. package/dist/plugins/pluginMcpIntegrity.js +74 -0
  211. package/dist/plugins/types.d.ts +30 -0
  212. package/dist/plugins/varRewrite.d.ts +9 -5
  213. package/dist/plugins/varRewrite.js +26 -17
  214. package/dist/preset/index.d.ts +11 -8
  215. package/dist/preset/index.js +62 -203
  216. package/dist/product/define.js +9 -3
  217. package/dist/product/types.d.ts +0 -2
  218. package/dist/profile/activation.d.ts +36 -0
  219. package/dist/profile/activation.js +57 -0
  220. package/dist/profile/catalog-store.d.ts +77 -0
  221. package/dist/profile/catalog-store.js +346 -0
  222. package/dist/profile/catalog.d.ts +54 -0
  223. package/dist/profile/catalog.js +152 -0
  224. package/dist/profile/index.d.ts +7 -0
  225. package/dist/profile/index.js +7 -0
  226. package/dist/profile/requirements.d.ts +76 -0
  227. package/dist/profile/requirements.js +117 -0
  228. package/dist/profile/resolve.d.ts +11 -0
  229. package/dist/profile/resolve.js +41 -0
  230. package/dist/profile/store.d.ts +17 -0
  231. package/dist/profile/store.js +167 -0
  232. package/dist/profile/types.d.ts +321 -0
  233. package/dist/profile/types.js +104 -0
  234. package/dist/prompt/composer.d.ts +46 -7
  235. package/dist/prompt/composer.js +78 -48
  236. package/dist/prompt/instruction-scanner.d.ts +6 -5
  237. package/dist/prompt/instruction-scanner.js +8 -23
  238. package/dist/prompt/section-loader.d.ts +2 -2
  239. package/dist/prompt/section-loader.js +7 -4
  240. package/dist/prompt/sections/browser.md +10 -9
  241. package/dist/prompt/sections/harness-base.md +9 -0
  242. package/dist/protocol/chat-session-manager.d.ts +77 -3
  243. package/dist/protocol/chat-session-manager.js +194 -18
  244. package/dist/protocol/chat-session.d.ts +72 -9
  245. package/dist/protocol/chat-session.js +151 -16
  246. package/dist/protocol/client.d.ts +37 -4
  247. package/dist/protocol/client.js +118 -6
  248. package/dist/protocol/index.d.ts +2 -2
  249. package/dist/protocol/index.js +2 -2
  250. package/dist/protocol/mobile-remote-types.d.ts +406 -0
  251. package/dist/protocol/mobile-remote-types.js +1 -0
  252. package/dist/protocol/server.d.ts +152 -14
  253. package/dist/protocol/server.js +1574 -229
  254. package/dist/protocol/types.d.ts +182 -32
  255. package/dist/protocol/types.js +18 -0
  256. package/dist/run/ArtifactTracker.d.ts +6 -1
  257. package/dist/run/ArtifactTracker.js +29 -21
  258. package/dist/run/EngineRunner.d.ts +2 -0
  259. package/dist/run/EngineRunner.js +1 -0
  260. package/dist/run/RunManager.d.ts +26 -0
  261. package/dist/run/RunManager.js +82 -11
  262. package/dist/run/factory.d.ts +3 -0
  263. package/dist/run/factory.js +2 -0
  264. package/dist/runtime/safe-spawn.d.ts +6 -0
  265. package/dist/runtime/safe-spawn.js +8 -13
  266. package/dist/services/auto-dream.d.ts +13 -1
  267. package/dist/services/auto-dream.js +56 -8
  268. package/dist/services/dream-consolidation.d.ts +3 -0
  269. package/dist/services/dream-consolidation.js +14 -2
  270. package/dist/services/index.d.ts +1 -1
  271. package/dist/services/index.js +1 -1
  272. package/dist/services/memory-orchestrator.js +6 -2
  273. package/dist/services/oauth.d.ts +34 -10
  274. package/dist/services/oauth.js +236 -98
  275. package/dist/services/session-memory.d.ts +4 -4
  276. package/dist/services/session-memory.js +13 -10
  277. package/dist/session/memory.d.ts +26 -7
  278. package/dist/session/memory.js +79 -21
  279. package/dist/session/session-manager.d.ts +208 -25
  280. package/dist/session/session-manager.js +1232 -116
  281. package/dist/session/session-message.d.ts +22 -0
  282. package/dist/session/session-message.js +1 -0
  283. package/dist/session/transcript.d.ts +76 -6
  284. package/dist/session/transcript.js +278 -7
  285. package/dist/settings/feature-flags.d.ts +40 -0
  286. package/dist/settings/feature-flags.js +40 -0
  287. package/dist/settings/manager.d.ts +39 -1
  288. package/dist/settings/manager.js +102 -41
  289. package/dist/settings/migrate-config.js +4 -2
  290. package/dist/settings/schema.d.ts +511 -177
  291. package/dist/settings/schema.js +98 -25
  292. package/dist/skills/scanner.d.ts +6 -2
  293. package/dist/skills/scanner.js +160 -9
  294. package/dist/sources/adapter.d.ts +14 -0
  295. package/dist/sources/adapter.js +7 -0
  296. package/dist/sources/adapters/local-files.d.ts +10 -0
  297. package/dist/sources/adapters/local-files.js +127 -0
  298. package/dist/sources/adapters/mcp-resource.d.ts +17 -0
  299. package/dist/sources/adapters/mcp-resource.js +41 -0
  300. package/dist/sources/adapters/mock.d.ts +3 -0
  301. package/dist/sources/adapters/mock.js +29 -0
  302. package/dist/sources/binding.d.ts +6 -0
  303. package/dist/sources/binding.js +24 -0
  304. package/dist/sources/catalog.d.ts +6 -0
  305. package/dist/sources/catalog.js +64 -0
  306. package/dist/sources/context-summary.d.ts +8 -0
  307. package/dist/sources/context-summary.js +13 -0
  308. package/dist/sources/credential-status.d.ts +2 -0
  309. package/dist/sources/credential-status.js +13 -0
  310. package/dist/sources/index.d.ts +10 -0
  311. package/dist/sources/index.js +10 -0
  312. package/dist/sources/resolve.d.ts +28 -0
  313. package/dist/sources/resolve.js +47 -0
  314. package/dist/sources/truncate-utf8.d.ts +7 -0
  315. package/dist/sources/truncate-utf8.js +18 -0
  316. package/dist/sources/types.d.ts +71 -0
  317. package/dist/sources/types.js +26 -0
  318. package/dist/testing/fetch-stub.d.ts +32 -0
  319. package/dist/testing/fetch-stub.js +24 -0
  320. package/dist/themes/image.d.ts +18 -0
  321. package/dist/themes/image.js +117 -0
  322. package/dist/themes/index.d.ts +4 -0
  323. package/dist/themes/index.js +4 -0
  324. package/dist/themes/installer.d.ts +50 -0
  325. package/dist/themes/installer.js +290 -0
  326. package/dist/themes/manifest.d.ts +102 -0
  327. package/dist/themes/manifest.js +86 -0
  328. package/dist/themes/paths.d.ts +14 -0
  329. package/dist/themes/paths.js +33 -0
  330. package/dist/tool-system/browser-bridge.d.ts +53 -11
  331. package/dist/tool-system/browser-bridge.js +8 -9
  332. package/dist/tool-system/builtin/agent-heartbeat.d.ts +2 -2
  333. package/dist/tool-system/builtin/agent-heartbeat.js +20 -6
  334. package/dist/tool-system/builtin/agent-notifications.d.ts +127 -72
  335. package/dist/tool-system/builtin/agent-notifications.js +291 -101
  336. package/dist/tool-system/builtin/agent-output-file.js +1 -3
  337. package/dist/tool-system/builtin/agent-progress.d.ts +6 -0
  338. package/dist/tool-system/builtin/agent-progress.js +83 -0
  339. package/dist/tool-system/builtin/agent-registry.d.ts +38 -2
  340. package/dist/tool-system/builtin/agent-registry.js +178 -9
  341. package/dist/tool-system/builtin/agent-transcript-translator.js +1 -1
  342. package/dist/tool-system/builtin/agent.d.ts +2 -0
  343. package/dist/tool-system/builtin/agent.js +279 -34
  344. package/dist/tool-system/builtin/background-jobs.d.ts +63 -7
  345. package/dist/tool-system/builtin/background-jobs.js +111 -15
  346. package/dist/tool-system/builtin/background-work.d.ts +12 -1
  347. package/dist/tool-system/builtin/background-work.js +12 -1
  348. package/dist/tool-system/builtin/bash.d.ts +3 -5
  349. package/dist/tool-system/builtin/bash.js +14 -6
  350. package/dist/tool-system/builtin/browser-tools.d.ts +8 -5
  351. package/dist/tool-system/builtin/browser-tools.js +89 -23
  352. package/dist/tool-system/builtin/edit-model-catalog.d.ts +6 -0
  353. package/dist/tool-system/builtin/edit-model-catalog.js +172 -12
  354. package/dist/tool-system/builtin/edit.d.ts +2 -1
  355. package/dist/tool-system/builtin/edit.js +14 -10
  356. package/dist/tool-system/builtin/file-cache.d.ts +2 -0
  357. package/dist/tool-system/builtin/file-cache.js +4 -0
  358. package/dist/tool-system/builtin/generate-image.js +17 -10
  359. package/dist/tool-system/builtin/generate-video.d.ts +4 -0
  360. package/dist/tool-system/builtin/generate-video.js +164 -28
  361. package/dist/tool-system/builtin/glob.d.ts +2 -1
  362. package/dist/tool-system/builtin/glob.js +28 -3
  363. package/dist/tool-system/builtin/grep.d.ts +1 -0
  364. package/dist/tool-system/builtin/grep.js +82 -17
  365. package/dist/tool-system/builtin/image-uploader.js +7 -1
  366. package/dist/tool-system/builtin/index.d.ts +57 -18
  367. package/dist/tool-system/builtin/index.js +280 -167
  368. package/dist/tool-system/builtin/memory.d.ts +6 -0
  369. package/dist/tool-system/builtin/memory.js +36 -15
  370. package/dist/tool-system/builtin/panel.d.ts +9 -0
  371. package/dist/tool-system/builtin/panel.js +314 -0
  372. package/dist/tool-system/builtin/read.js +5 -1
  373. package/dist/tool-system/builtin/send-message-to-session.d.ts +6 -0
  374. package/dist/tool-system/builtin/send-message-to-session.js +67 -0
  375. package/dist/tool-system/builtin/sources.d.ts +8 -0
  376. package/dist/tool-system/builtin/sources.js +137 -0
  377. package/dist/tool-system/builtin/tool-search.js +7 -2
  378. package/dist/tool-system/builtin/update-automation-memory.js +2 -0
  379. package/dist/tool-system/builtin/video-providers.d.ts +12 -15
  380. package/dist/tool-system/builtin/video-providers.js +1 -0
  381. package/dist/tool-system/builtin/view-image.d.ts +2 -2
  382. package/dist/tool-system/builtin/web-fetch.js +60 -15
  383. package/dist/tool-system/builtin/web-search.js +1 -3
  384. package/dist/tool-system/builtin/write.d.ts +2 -1
  385. package/dist/tool-system/builtin/write.js +14 -4
  386. package/dist/tool-system/capability-module.d.ts +93 -0
  387. package/dist/tool-system/capability-module.js +53 -0
  388. package/dist/tool-system/context.d.ts +94 -24
  389. package/dist/tool-system/executor.js +99 -58
  390. package/dist/tool-system/external-tool-exposure.d.ts +60 -0
  391. package/dist/tool-system/external-tool-exposure.js +304 -0
  392. package/dist/tool-system/mcp-manager.d.ts +17 -1
  393. package/dist/tool-system/mcp-manager.js +72 -7
  394. package/dist/tool-system/mcp-tool-policy.d.ts +9 -0
  395. package/dist/tool-system/mcp-tool-policy.js +34 -0
  396. package/dist/tool-system/panel-bridge.d.ts +53 -0
  397. package/dist/tool-system/panel-bridge.js +1 -0
  398. package/dist/tool-system/path-policy.d.ts +19 -0
  399. package/dist/tool-system/path-policy.js +62 -1
  400. package/dist/tool-system/permission.d.ts +69 -3
  401. package/dist/tool-system/permission.js +429 -30
  402. package/dist/tool-system/registry.d.ts +15 -3
  403. package/dist/tool-system/registry.js +82 -40
  404. package/dist/tool-system/sandbox/index.js +3 -8
  405. package/dist/tool-system/sandbox/seatbelt.js +2 -6
  406. package/dist/tool-system/session-tool-host.d.ts +139 -0
  407. package/dist/tool-system/session-tool-host.js +167 -0
  408. package/dist/tool-system/testing/tool-registry-harness.d.ts +31 -0
  409. package/dist/tool-system/testing/tool-registry-harness.js +51 -0
  410. package/dist/tool-system/validation.d.ts +14 -7
  411. package/dist/tool-system/validation.js +701 -7
  412. package/dist/types.d.ts +201 -20
  413. package/dist/{cc-orchestrator → utils}/cwd-normalize.d.ts +1 -0
  414. package/dist/{cc-orchestrator → utils}/cwd-normalize.js +1 -0
  415. package/dist/utils/file-mutex.d.ts +65 -0
  416. package/dist/utils/file-mutex.js +145 -0
  417. package/dist/utils/json.d.ts +2 -4
  418. package/dist/utils/json.js +2 -4
  419. package/dist/utils/lockfile.d.ts +30 -1
  420. package/dist/utils/lockfile.js +2 -2
  421. package/dist/utils/secret-scrubber.d.ts +8 -0
  422. package/dist/utils/secret-scrubber.js +227 -0
  423. package/dist/utils/toolDisplay.js +1 -1
  424. package/package.json +20 -5
  425. package/THIRD_PARTY_NOTICES.md +0 -206
  426. package/dist/arena/arena.d.ts +0 -43
  427. package/dist/arena/arena.js +0 -333
  428. package/dist/arena/context/context-tools.d.ts +0 -16
  429. package/dist/arena/context/context-tools.js +0 -272
  430. package/dist/arena/context/within-root.d.ts +0 -7
  431. package/dist/arena/context/within-root.js +0 -15
  432. package/dist/arena/detect-mode.d.ts +0 -20
  433. package/dist/arena/detect-mode.js +0 -78
  434. package/dist/arena/digest-builder.d.ts +0 -25
  435. package/dist/arena/digest-builder.js +0 -120
  436. package/dist/arena/index.d.ts +0 -29
  437. package/dist/arena/index.js +0 -29
  438. package/dist/arena/iterate/convergence.d.ts +0 -25
  439. package/dist/arena/iterate/convergence.js +0 -103
  440. package/dist/arena/iterate/formats/index.d.ts +0 -22
  441. package/dist/arena/iterate/formats/index.js +0 -283
  442. package/dist/arena/iterate/index.d.ts +0 -11
  443. package/dist/arena/iterate/index.js +0 -9
  444. package/dist/arena/iterate/iterative-arena.d.ts +0 -23
  445. package/dist/arena/iterate/iterative-arena.js +0 -237
  446. package/dist/arena/iterate/parse.d.ts +0 -42
  447. package/dist/arena/iterate/parse.js +0 -123
  448. package/dist/arena/iterate/phases/argue.d.ts +0 -22
  449. package/dist/arena/iterate/phases/argue.js +0 -165
  450. package/dist/arena/iterate/phases/revise.d.ts +0 -16
  451. package/dist/arena/iterate/phases/revise.js +0 -62
  452. package/dist/arena/iterate/phases/tournament.d.ts +0 -34
  453. package/dist/arena/iterate/phases/tournament.js +0 -113
  454. package/dist/arena/iterate/tools/web-tools.d.ts +0 -13
  455. package/dist/arena/iterate/tools/web-tools.js +0 -54
  456. package/dist/arena/iterate/types.d.ts +0 -152
  457. package/dist/arena/iterate/types.js +0 -8
  458. package/dist/arena/ledger.d.ts +0 -47
  459. package/dist/arena/ledger.js +0 -159
  460. package/dist/arena/lenses/architecture.d.ts +0 -5
  461. package/dist/arena/lenses/architecture.js +0 -22
  462. package/dist/arena/lenses/engineering.d.ts +0 -5
  463. package/dist/arena/lenses/engineering.js +0 -22
  464. package/dist/arena/lenses/general.d.ts +0 -5
  465. package/dist/arena/lenses/general.js +0 -20
  466. package/dist/arena/lenses/index.d.ts +0 -16
  467. package/dist/arena/lenses/index.js +0 -47
  468. package/dist/arena/lenses/product.d.ts +0 -5
  469. package/dist/arena/lenses/product.js +0 -22
  470. package/dist/arena/model-presets.d.ts +0 -23
  471. package/dist/arena/model-presets.js +0 -44
  472. package/dist/arena/phases/adjudication.d.ts +0 -24
  473. package/dist/arena/phases/adjudication.js +0 -141
  474. package/dist/arena/phases/build-consensus.d.ts +0 -29
  475. package/dist/arena/phases/build-consensus.js +0 -83
  476. package/dist/arena/phases/claim-registry.d.ts +0 -26
  477. package/dist/arena/phases/claim-registry.js +0 -60
  478. package/dist/arena/phases/cross-review.d.ts +0 -45
  479. package/dist/arena/phases/cross-review.js +0 -218
  480. package/dist/arena/phases/debate-rounds.d.ts +0 -27
  481. package/dist/arena/phases/debate-rounds.js +0 -159
  482. package/dist/arena/phases/participant-research.d.ts +0 -38
  483. package/dist/arena/phases/participant-research.js +0 -314
  484. package/dist/arena/phases/planning-detail-expansion.d.ts +0 -38
  485. package/dist/arena/phases/planning-detail-expansion.js +0 -119
  486. package/dist/arena/planner.d.ts +0 -27
  487. package/dist/arena/planner.js +0 -311
  488. package/dist/arena/providers/docs.d.ts +0 -7
  489. package/dist/arena/providers/docs.js +0 -111
  490. package/dist/arena/providers/git.d.ts +0 -8
  491. package/dist/arena/providers/git.js +0 -174
  492. package/dist/arena/providers/index.d.ts +0 -32
  493. package/dist/arena/providers/index.js +0 -132
  494. package/dist/arena/providers/none.d.ts +0 -7
  495. package/dist/arena/providers/none.js +0 -11
  496. package/dist/arena/providers/repo.d.ts +0 -7
  497. package/dist/arena/providers/repo.js +0 -258
  498. package/dist/arena/render/session.d.ts +0 -17
  499. package/dist/arena/render/session.js +0 -190
  500. package/dist/arena/render/terminal.d.ts +0 -34
  501. package/dist/arena/render/terminal.js +0 -286
  502. package/dist/arena/strategies/discussion.d.ts +0 -25
  503. package/dist/arena/strategies/discussion.js +0 -143
  504. package/dist/arena/strategies/index.d.ts +0 -15
  505. package/dist/arena/strategies/index.js +0 -28
  506. package/dist/arena/strategies/language-wrapper.d.ts +0 -17
  507. package/dist/arena/strategies/language-wrapper.js +0 -102
  508. package/dist/arena/strategies/lens-wrapper.d.ts +0 -16
  509. package/dist/arena/strategies/lens-wrapper.js +0 -236
  510. package/dist/arena/strategies/planning.d.ts +0 -30
  511. package/dist/arena/strategies/planning.js +0 -225
  512. package/dist/arena/strategies/review.d.ts +0 -26
  513. package/dist/arena/strategies/review.js +0 -168
  514. package/dist/arena/strategies/utils.d.ts +0 -36
  515. package/dist/arena/strategies/utils.js +0 -603
  516. package/dist/arena/tools/selector.d.ts +0 -17
  517. package/dist/arena/tools/selector.js +0 -61
  518. package/dist/arena/transitions.d.ts +0 -53
  519. package/dist/arena/transitions.js +0 -97
  520. package/dist/arena/types.d.ts +0 -514
  521. package/dist/arena/types.js +0 -27
  522. package/dist/cc-orchestrator/agent-adapter.d.ts +0 -54
  523. package/dist/cc-orchestrator/agent-adapter.js +0 -143
  524. package/dist/cc-orchestrator/cc-capability.d.ts +0 -19
  525. package/dist/cc-orchestrator/cc-capability.js +0 -53
  526. package/dist/cc-orchestrator/codex-session-discovery.d.ts +0 -24
  527. package/dist/cc-orchestrator/codex-session-discovery.js +0 -191
  528. package/dist/cc-orchestrator/codex-session-history.d.ts +0 -38
  529. package/dist/cc-orchestrator/codex-session-history.js +0 -247
  530. package/dist/cc-orchestrator/external-agent-bindings.d.ts +0 -27
  531. package/dist/cc-orchestrator/external-agent-bindings.js +0 -150
  532. package/dist/cc-orchestrator/external-agent-changes.d.ts +0 -19
  533. package/dist/cc-orchestrator/external-agent-changes.js +0 -231
  534. package/dist/cc-orchestrator/external-agent-driver.d.ts +0 -19
  535. package/dist/cc-orchestrator/external-agent-driver.js +0 -284
  536. package/dist/cc-orchestrator/external-agent-session-store.d.ts +0 -23
  537. package/dist/cc-orchestrator/external-agent-session-store.js +0 -146
  538. package/dist/cc-orchestrator/index.d.ts +0 -8
  539. package/dist/cc-orchestrator/index.js +0 -8
  540. package/dist/cc-orchestrator/relevance-judge.d.ts +0 -15
  541. package/dist/cc-orchestrator/relevance-judge.js +0 -29
  542. package/dist/cc-orchestrator/session-discovery.d.ts +0 -46
  543. package/dist/cc-orchestrator/session-discovery.js +0 -125
  544. package/dist/cc-orchestrator/session-history.d.ts +0 -54
  545. package/dist/cc-orchestrator/session-history.js +0 -150
  546. package/dist/cron/cron-runtime.d.ts +0 -2
  547. package/dist/cron/cron-runtime.js +0 -2
  548. package/dist/cron/cron-store.d.ts +0 -2
  549. package/dist/cron/cron-store.js +0 -2
  550. package/dist/cron/scheduler.d.ts +0 -7
  551. package/dist/cron/scheduler.js +0 -7
  552. package/dist/engine/goal.js +0 -206
  553. package/dist/external-agents/config.d.ts +0 -2
  554. package/dist/external-agents/config.js +0 -15
  555. package/dist/external-agents/types.d.ts +0 -31
  556. package/dist/git/parse-log.d.ts +0 -13
  557. package/dist/git/parse-log.js +0 -21
  558. package/dist/git/utils.d.ts +0 -49
  559. package/dist/git/utils.js +0 -161
  560. package/dist/git/worktree/crud.d.ts +0 -69
  561. package/dist/git/worktree/crud.js +0 -206
  562. package/dist/git/worktree/diff.d.ts +0 -14
  563. package/dist/git/worktree/diff.js +0 -82
  564. package/dist/git/worktree/git-exec.d.ts +0 -7
  565. package/dist/git/worktree/git-exec.js +0 -51
  566. package/dist/git/worktree/index.d.ts +0 -5
  567. package/dist/git/worktree/index.js +0 -5
  568. package/dist/git/worktree/query.d.ts +0 -42
  569. package/dist/git/worktree/query.js +0 -121
  570. package/dist/git/worktree/slug.d.ts +0 -11
  571. package/dist/git/worktree/slug.js +0 -58
  572. package/dist/git/worktree.d.ts +0 -1
  573. package/dist/git/worktree.js +0 -5
  574. package/dist/lsp/client.d.ts +0 -41
  575. package/dist/lsp/client.js +0 -186
  576. package/dist/lsp/manager.d.ts +0 -40
  577. package/dist/lsp/manager.js +0 -155
  578. package/dist/lsp/root-path.d.ts +0 -9
  579. package/dist/lsp/root-path.js +0 -12
  580. package/dist/lsp/servers.d.ts +0 -16
  581. package/dist/lsp/servers.js +0 -60
  582. package/dist/prompt/sections/coding.md +0 -35
  583. package/dist/quota/credentials.d.ts +0 -3
  584. package/dist/quota/credentials.js +0 -80
  585. package/dist/quota/index.d.ts +0 -36
  586. package/dist/quota/index.js +0 -155
  587. package/dist/quota/types.d.ts +0 -48
  588. package/dist/quota/types.js +0 -13
  589. package/dist/review/review-prompt.d.ts +0 -28
  590. package/dist/review/review-prompt.js +0 -81
  591. package/dist/state.d.ts +0 -160
  592. package/dist/state.js +0 -267
  593. package/dist/tool-system/builtin/apply-patch/applier.d.ts +0 -26
  594. package/dist/tool-system/builtin/apply-patch/applier.js +0 -308
  595. package/dist/tool-system/builtin/apply-patch/backup-targets.d.ts +0 -10
  596. package/dist/tool-system/builtin/apply-patch/backup-targets.js +0 -30
  597. package/dist/tool-system/builtin/apply-patch/index.d.ts +0 -20
  598. package/dist/tool-system/builtin/apply-patch/index.js +0 -105
  599. package/dist/tool-system/builtin/apply-patch/parser.d.ts +0 -17
  600. package/dist/tool-system/builtin/apply-patch/parser.js +0 -212
  601. package/dist/tool-system/builtin/apply-patch/seek-sequence.d.ts +0 -18
  602. package/dist/tool-system/builtin/apply-patch/seek-sequence.js +0 -123
  603. package/dist/tool-system/builtin/apply-patch/types.d.ts +0 -49
  604. package/dist/tool-system/builtin/apply-patch/types.js +0 -13
  605. package/dist/tool-system/builtin/arena.d.ts +0 -31
  606. package/dist/tool-system/builtin/arena.js +0 -415
  607. package/dist/tool-system/builtin/brief.d.ts +0 -6
  608. package/dist/tool-system/builtin/brief.js +0 -41
  609. package/dist/tool-system/builtin/check-quota.d.ts +0 -15
  610. package/dist/tool-system/builtin/check-quota.js +0 -34
  611. package/dist/tool-system/builtin/drive-claude-code.d.ts +0 -51
  612. package/dist/tool-system/builtin/drive-claude-code.js +0 -698
  613. package/dist/tool-system/builtin/lsp.d.ts +0 -7
  614. package/dist/tool-system/builtin/lsp.js +0 -144
  615. package/dist/tool-system/builtin/notebook-edit.d.ts +0 -7
  616. package/dist/tool-system/builtin/notebook-edit.js +0 -127
  617. package/dist/tool-system/builtin/worktree.d.ts +0 -11
  618. package/dist/tool-system/builtin/worktree.js +0 -345
  619. /package/dist/{external-agents → links}/types.js +0 -0
  620. /package/dist/{engine/session-usage.d.ts → session/usage.d.ts} +0 -0
  621. /package/dist/{engine/session-usage.js → session/usage.js} +0 -0
@@ -1,15 +1,169 @@
1
1
  /**
2
2
  * Session lifecycle manager.
3
3
  */
4
- import { closeSync, existsSync, mkdirSync, lstatSync, openSync, readFileSync, readSync, readdirSync, renameSync, statSync, writeFileSync, } from "node:fs";
4
+ import { closeSync, existsSync, mkdirSync, lstatSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
5
5
  import { join } from "node:path";
6
6
  import { homedir } from "node:os";
7
+ import { createHash } from "node:crypto";
7
8
  import { nanoid } from "nanoid";
8
9
  import { Transcript } from "./transcript.js";
9
10
  import { SessionError } from "../exceptions.js";
10
- import { normalizeCumulativeUsageCounters } from "../engine/session-usage.js";
11
- import { isSameGoalInstance } from "../engine/goal.js";
12
- import { branchExists, isGitWorktreeRoot } from "../git/worktree.js";
11
+ import { addCumulativeUsage, addTokenUsage, normalizeCumulativeUsageCounters } from "./usage.js";
12
+ import { armGoalLifecycle, createGoalLifecycle, decodeGoalLifecycle, deriveLegacyGoalId, goalConfigFromLifecycle, isGoalLifecycleCurrent, isSameGoalVersion, mergeGoalTerminals, terminateGoalLifecycle, waitGoalLifecycle, } from "../goal/lifecycle.js";
13
+ import { lockSync } from "../utils/lockfile.js";
14
+ import { resolveCapabilities } from "../capabilities/index.js";
15
+ // Shared close epochs for SessionManager instances in this process. Concurrent
16
+ // Engines bind the same epoch; only close advances it. This intentionally does
17
+ // not claim cross-process/Worker protection.
18
+ const currentSessionCloseEpochs = new Map();
19
+ const SESSION_STATE_LOCK_STALE_MS = 10_000;
20
+ const SESSION_STATE_LOCK_RETRY_DELAYS_MS = [5, 10, 20, 40];
21
+ const syncSleepCell = new Int32Array(new SharedArrayBuffer(4));
22
+ function sleepSync(ms) {
23
+ Atomics.wait(syncSleepCell, 0, 0, ms);
24
+ }
25
+ function lifecycleTerminalReason(reason) {
26
+ return reason;
27
+ }
28
+ function newestLegacyTerminal(state) {
29
+ return mergeGoalTerminals(state.goalTerminals, state.goalTerminal).at(-1);
30
+ }
31
+ function legacyTerminalMatchesGoal(terminal, goal) {
32
+ if (terminal.goalId && goal.goalId) {
33
+ return (terminal.goalId === goal.goalId &&
34
+ Math.max(1, Math.floor(terminal.revision ?? 1)) ===
35
+ Math.max(1, Math.floor(goal.revision ?? 1)));
36
+ }
37
+ return terminal.objective === goal.objective && terminal.setAtMs === goal.setAtMs;
38
+ }
39
+ /** Decode the canonical union or derive it once from legacy aliases. */
40
+ function hydrateGoalLifecycle(state) {
41
+ const hasCanonical = Object.prototype.hasOwnProperty.call(state, "goalLifecycle");
42
+ const persistedLifecycle = state.goalLifecycle;
43
+ let lifecycle;
44
+ if (hasCanonical && persistedLifecycle !== undefined) {
45
+ lifecycle = decodeGoalLifecycle(persistedLifecycle);
46
+ if (!lifecycle) {
47
+ throw new SessionError(`Session goal lifecycle is unsupported or corrupt for ${state.sessionId}`);
48
+ }
49
+ }
50
+ else if (state.activeGoal) {
51
+ const active = {
52
+ ...state.activeGoal,
53
+ goalId: state.activeGoal.goalId ?? deriveLegacyGoalId(state.sessionId, state.activeGoal),
54
+ revision: Math.max(1, Math.floor(state.activeGoal.revision ?? 1)),
55
+ };
56
+ const terminal = mergeGoalTerminals(state.goalTerminals, state.goalTerminal).find((candidate) => legacyTerminalMatchesGoal(candidate, active));
57
+ const base = createGoalLifecycle(active, active.paused === true ? "paused" : "active");
58
+ lifecycle = terminal
59
+ ? terminateGoalLifecycle(base, lifecycleTerminalReason(terminal.reason), terminal.terminatedAtMs ?? Date.now())
60
+ : base;
61
+ }
62
+ else {
63
+ const terminal = newestLegacyTerminal(state);
64
+ if (terminal) {
65
+ const goal = {
66
+ objective: terminal.objective,
67
+ goalId: terminal.goalId ??
68
+ deriveLegacyGoalId(state.sessionId, {
69
+ objective: terminal.objective,
70
+ setAtMs: terminal.setAtMs,
71
+ }),
72
+ revision: Math.max(1, Math.floor(terminal.revision ?? 1)),
73
+ ...(terminal.setAtMs !== undefined ? { setAtMs: terminal.setAtMs } : {}),
74
+ };
75
+ lifecycle = terminateGoalLifecycle(createGoalLifecycle(goal, "active", terminal.terminatedAtMs ?? Date.now()), lifecycleTerminalReason(terminal.reason), terminal.terminatedAtMs ?? Date.now());
76
+ }
77
+ }
78
+ if (lifecycle)
79
+ state.goalLifecycle = lifecycle;
80
+ else
81
+ delete state.goalLifecycle;
82
+ // Legacy aliases are migration inputs only. Never reconstruct them in RAM:
83
+ // all runtime readers and writers consume the canonical lifecycle union.
84
+ delete state.activeGoal;
85
+ delete state.goalTerminal;
86
+ delete state.goalTerminals;
87
+ return state;
88
+ }
89
+ /** New files and runtime state contain only the canonical union. */
90
+ function stateForPersistence(state) {
91
+ const persisted = { ...state };
92
+ delete persisted.activeGoal;
93
+ delete persisted.goalTerminal;
94
+ delete persisted.goalTerminals;
95
+ return persisted;
96
+ }
97
+ /**
98
+ * Temporary adapter for tests/older in-repo callers that still mutate the
99
+ * hydrated aliases before calling whole-state saveState. Production Goal paths
100
+ * use the domain methods below. The adapter never writes aliases to disk.
101
+ */
102
+ function adoptCompatibilityGoalMutation(state) {
103
+ const activeTouched = Object.prototype.hasOwnProperty.call(state, "activeGoal");
104
+ const terminalTouched = Object.prototype.hasOwnProperty.call(state, "goalTerminal") ||
105
+ Object.prototype.hasOwnProperty.call(state, "goalTerminals");
106
+ if (!activeTouched && !terminalTouched)
107
+ return;
108
+ const lifecycle = decodeGoalLifecycle(state.goalLifecycle);
109
+ const alias = state.activeGoal
110
+ ? {
111
+ ...state.activeGoal,
112
+ goalId: state.activeGoal.goalId ?? deriveLegacyGoalId(state.sessionId, state.activeGoal),
113
+ revision: Math.max(1, Math.floor(state.activeGoal.revision ?? 1)),
114
+ }
115
+ : undefined;
116
+ const terminals = mergeGoalTerminals(state.goalTerminals, state.goalTerminal);
117
+ if (!lifecycle) {
118
+ if (alias) {
119
+ state.goalLifecycle = createGoalLifecycle(alias, alias.paused === true ? "paused" : "active");
120
+ }
121
+ return;
122
+ }
123
+ if (alias) {
124
+ const sameIdentity = lifecycle.goalId === alias.goalId;
125
+ const sameRevision = lifecycle.revision === alias.revision;
126
+ const matchingTerminal = terminals.find((terminal) => legacyTerminalMatchesGoal(terminal, alias));
127
+ if (matchingTerminal) {
128
+ const base = createGoalLifecycle(alias, alias.paused === true ? "paused" : "active");
129
+ state.goalLifecycle = terminateGoalLifecycle(base, lifecycleTerminalReason(matchingTerminal.reason), matchingTerminal.terminatedAtMs ?? Date.now());
130
+ return;
131
+ }
132
+ if (lifecycle.phase === "terminal" && sameIdentity && sameRevision)
133
+ return;
134
+ const canonicalGoal = goalConfigFromLifecycle(lifecycle);
135
+ if (!sameIdentity || !sameRevision || !isSameGoalVersion(alias, canonicalGoal)) {
136
+ state.goalLifecycle = createGoalLifecycle(alias, alias.paused === true ? "paused" : "active");
137
+ }
138
+ return;
139
+ }
140
+ const matchingTerminal = terminals.find((terminal) => legacyTerminalMatchesGoal(terminal, goalConfigFromLifecycle(lifecycle)));
141
+ if (matchingTerminal && lifecycle.phase !== "terminal") {
142
+ state.goalLifecycle = terminateGoalLifecycle(lifecycle, lifecycleTerminalReason(matchingTerminal.reason), matchingTerminal.terminatedAtMs ?? Date.now());
143
+ }
144
+ }
145
+ const FORK_COPY_EVENT_TYPES = new Set([
146
+ "message",
147
+ "tool_use",
148
+ "tool_result",
149
+ "summary",
150
+ "context_transfer",
151
+ "content_replace",
152
+ "subagent",
153
+ "external_file_changes",
154
+ "goal_progress",
155
+ "turn_boundary",
156
+ "turn_stopped",
157
+ "error",
158
+ ]);
159
+ const FORK_SKIP_EVENT_TYPES = new Set([
160
+ "session_meta",
161
+ "file_history",
162
+ "plan_operation",
163
+ ]);
164
+ const FORK_STAGING_NAME = /^\.pending-fork-[A-Za-z0-9_.-]+-[A-Za-z0-9_-]{8}$/;
165
+ const FORK_STAGING_MAX_AGE_MS = 24 * 60 * 60 * 1000;
166
+ const FORK_STAGING_CLEANUP_LIMIT = 32;
13
167
  /**
14
168
  * Validate a session ID before it is joined into a filesystem path.
15
169
  *
@@ -47,6 +201,18 @@ export function assertSafeSessionId(sessionId) {
47
201
  throw new SessionError(`invalid session id: too long (max 128 chars)`);
48
202
  }
49
203
  }
204
+ /**
205
+ * Runtime source of truth for sessions whose state must not outlive their UI
206
+ * lifecycle. The explicit marker covers modern side forks; the qchat namespace
207
+ * keeps historical/blank quick chats fail-closed if their older state file did
208
+ * not contain the marker yet.
209
+ */
210
+ export function isEphemeralSessionState(state) {
211
+ return state.ephemeral === true || state.sessionId.startsWith("qchat-");
212
+ }
213
+ function normalizedSessionKind(kind) {
214
+ return kind === "pet" ? "pet" : "work";
215
+ }
50
216
  /**
51
217
  * Resolve the `.code-shell` home dir. `CODE_SHELL_HOME` overrides the default
52
218
  * `~/.code-shell` — mirrors Codex's `CODEX_HOME`. Tests set it to a temp dir
@@ -57,6 +223,14 @@ export function assertSafeSessionId(sessionId) {
57
223
  export function codeShellHome() {
58
224
  return process.env.CODE_SHELL_HOME || join(homedir(), ".code-shell");
59
225
  }
226
+ /**
227
+ * Canonical root for every persisted CodeShell session. An explicit `home`
228
+ * (a `~/.code-shell`-equivalent data root) overrides the default resolution;
229
+ * absent → `codeShellHome()` exactly as before.
230
+ */
231
+ export function sessionsRoot(home) {
232
+ return join(home ?? codeShellHome(), "sessions");
233
+ }
60
234
  function isSessionWorkspace(value) {
61
235
  if (!value || typeof value !== "object")
62
236
  return false;
@@ -77,7 +251,16 @@ function isSessionWorkspace(value) {
77
251
  wt.baseRef.length > 0 &&
78
252
  wt.createdBy === "codeshell");
79
253
  }
80
- async function validateResumeWorktreeRoot(root) {
254
+ /**
255
+ * Read the session's persisted main-project root from the legacy `state.cwd`
256
+ * field. The field name is part of the state.json compatibility contract and
257
+ * must not be rewritten to `projectRoot`; `state.workspace.root` separately
258
+ * identifies the current main/worktree execution root used on resume.
259
+ */
260
+ export function sessionMainRoot(state) {
261
+ return typeof state.cwd === "string" && state.cwd.length > 0 ? state.cwd : undefined;
262
+ }
263
+ async function validateResumeWorktreeRoot(root, workspaceCapability) {
81
264
  if (!existsSync(root))
82
265
  return "no longer exists";
83
266
  let stat;
@@ -91,15 +274,66 @@ async function validateResumeWorktreeRoot(root) {
91
274
  return "is not a valid git worktree (symbolic link)";
92
275
  if (!stat.isDirectory())
93
276
  return "is not a valid git worktree (not a directory)";
94
- if (!(await isGitWorktreeRoot(root)))
95
- return "is not a valid git worktree";
277
+ if (!(await workspaceCapability.validateRoot(root)))
278
+ return "is not a valid workspace root";
96
279
  return null;
97
280
  }
98
281
  export class SessionManager {
99
282
  sessionsDir;
100
- constructor(storageDir) {
101
- this.sessionsDir = storageDir ?? join(codeShellHome(), "sessions");
283
+ registeredCloseEpochs = new Map();
284
+ workspaceCapability;
285
+ constructor(storageDir, workspaceCapability) {
286
+ this.sessionsDir = storageDir ?? sessionsRoot();
287
+ this.workspaceCapability =
288
+ workspaceCapability ??
289
+ resolveCapabilities()
290
+ .map((capability) => capability.sessionWorkspace)
291
+ .find((candidate) => candidate !== undefined);
102
292
  mkdirSync(this.sessionsDir, { recursive: true });
293
+ this.cleanupStaleForkStaging();
294
+ }
295
+ cleanupStaleForkStaging() {
296
+ let removed = 0;
297
+ let entries;
298
+ try {
299
+ entries = readdirSync(this.sessionsDir, { withFileTypes: true });
300
+ }
301
+ catch {
302
+ return;
303
+ }
304
+ for (const entry of entries) {
305
+ if (removed >= FORK_STAGING_CLEANUP_LIMIT)
306
+ return;
307
+ if (!entry.isDirectory() || !FORK_STAGING_NAME.test(entry.name))
308
+ continue;
309
+ const path = join(this.sessionsDir, entry.name);
310
+ try {
311
+ if (Date.now() - statSync(path).mtimeMs <= FORK_STAGING_MAX_AGE_MS)
312
+ continue;
313
+ rmSync(path, { recursive: true, force: true });
314
+ removed++;
315
+ }
316
+ catch {
317
+ // Startup cleanup is best effort; a live/unreadable staging directory
318
+ // must never prevent the SessionManager from serving normal sessions.
319
+ }
320
+ }
321
+ }
322
+ /** Bind one Engine/session pair to the current close epoch without advancing it. */
323
+ registerSessionGeneration(sessionId) {
324
+ assertSafeSessionId(sessionId);
325
+ const key = this.generationKey(sessionId);
326
+ const generation = currentSessionCloseEpochs.get(key) ?? 0;
327
+ this.registeredCloseEpochs.set(sessionId, generation);
328
+ return generation;
329
+ }
330
+ /** Advance the close epoch once before close waits for the old run to settle. */
331
+ incrementSessionGeneration(sessionId) {
332
+ assertSafeSessionId(sessionId);
333
+ const key = this.generationKey(sessionId);
334
+ const next = (currentSessionCloseEpochs.get(key) ?? 0) + 1;
335
+ currentSessionCloseEpochs.set(key, next);
336
+ return next;
103
337
  }
104
338
  /**
105
339
  * Create a new on-disk session. If `explicitSessionId` is passed, use
@@ -108,7 +342,7 @@ export class SessionManager {
108
342
  * nanoid. Either way the on-disk directory is materialized and the
109
343
  * state.json + transcript.jsonl files are written before return.
110
344
  */
111
- create(cwd, model, provider, explicitSessionId, parentSessionId, origin) {
345
+ create(cwd, model, provider, explicitSessionId, parentSessionId, origin, kind = "work") {
112
346
  // External callers may pass any string; nanoid output is trusted. Either
113
347
  // way the ID gets joined into a filesystem path, so the public entry
114
348
  // point validates before that join.
@@ -127,6 +361,8 @@ export class SessionManager {
127
361
  }
128
362
  const state = {
129
363
  sessionId,
364
+ kind,
365
+ stateRevision: 0,
130
366
  cwd,
131
367
  workspace: { root: cwd, kind: "main" },
132
368
  startedAt: Date.now(),
@@ -144,6 +380,7 @@ export class SessionManager {
144
380
  // new top-level session (key present, null) apart from a legacy session
145
381
  // (key absent) and from a sub-agent (key present, non-empty string).
146
382
  parentSessionId: parentSessionId ?? null,
383
+ ...(sessionId.startsWith("qchat-") ? { ephemeral: true } : {}),
147
384
  ...(origin ? { origin } : {}),
148
385
  };
149
386
  // Atomic write (tmp+rename) like saveState, so a crash during this one-time
@@ -159,6 +396,7 @@ export class SessionManager {
159
396
  model,
160
397
  provider,
161
398
  startedAt: state.startedAt,
399
+ kind,
162
400
  });
163
401
  return { state, transcript };
164
402
  }
@@ -180,16 +418,124 @@ export class SessionManager {
180
418
  return existsSync(join(this.sessionsDir, sessionId));
181
419
  }
182
420
  /**
183
- * Cheap "what cwd is this session bound to?" probe — reads only state.json,
184
- * NOT the transcript (unlike resume()). engine.run uses this to recover a
185
- * resumed session's project directory when the caller omits options.cwd
186
- * (e.g. a desktop host whose sidebar repo selection has drifted to null),
187
- * so a project-bound session keeps loading its own agents/settings/memory
188
- * instead of falling back to process.cwd(). Returns undefined for an
189
- * unknown, malformed, or traversal-shaped id rather than throwing — the
190
- * caller treats "no recoverable cwd" the same as "not given".
421
+ * Cheap persisted-main-root probe — reads only state.json, NOT the transcript
422
+ * (unlike resume()). This deliberately reads legacy `state.cwd`, not the
423
+ * current `state.workspace.root`; callers use it as the stable main-project
424
+ * fallback when a worktree is unavailable or is being released.
191
425
  */
426
+ readSessionMainRoot(sessionId) {
427
+ try {
428
+ assertSafeSessionId(sessionId);
429
+ }
430
+ catch {
431
+ return undefined;
432
+ }
433
+ const stateFile = join(this.sessionsDir, sessionId, "state.json");
434
+ if (!existsSync(stateFile))
435
+ return undefined;
436
+ try {
437
+ const state = JSON.parse(readFileSync(stateFile, "utf-8"));
438
+ return sessionMainRoot(state);
439
+ }
440
+ catch {
441
+ return undefined;
442
+ }
443
+ }
444
+ /** Cheap durable classification read. Legacy sessions are ordinary work sessions. */
445
+ readSessionKind(sessionId) {
446
+ try {
447
+ assertSafeSessionId(sessionId);
448
+ }
449
+ catch {
450
+ return undefined;
451
+ }
452
+ const stateFile = join(this.sessionsDir, sessionId, "state.json");
453
+ if (!existsSync(stateFile))
454
+ return undefined;
455
+ try {
456
+ const state = JSON.parse(readFileSync(stateFile, "utf-8"));
457
+ return normalizedSessionKind(state.kind);
458
+ }
459
+ catch {
460
+ return undefined;
461
+ }
462
+ }
463
+ /** Cheap durable digital-human binding read. */
464
+ readSessionWorkspaceProfile(sessionId) {
465
+ try {
466
+ assertSafeSessionId(sessionId);
467
+ const stateFile = join(this.sessionsDir, sessionId, "state.json");
468
+ if (!existsSync(stateFile))
469
+ return undefined;
470
+ const state = JSON.parse(readFileSync(stateFile, "utf-8"));
471
+ return typeof state.workspaceProfile === "string" && state.workspaceProfile
472
+ ? state.workspaceProfile
473
+ : undefined;
474
+ }
475
+ catch {
476
+ return undefined;
477
+ }
478
+ }
479
+ /**
480
+ * Find durable Sessions currently bound to one digital-human profile without loading
481
+ * transcripts. Used by profile deletion guards so a reusable/historical
482
+ * Session cannot be left permanently unresumable.
483
+ */
484
+ /**
485
+ * Sessions whose state pins this digital human.
486
+ *
487
+ * `includeArchived: false` drops archived Sessions: they are closed history,
488
+ * not a live binding, so they should not block deleting the profile. A
489
+ * dangling name is already handled downstream — resolveActiveWorkspaceProfile
490
+ * logs `profile.active_missing_from_library` and returns undefined.
491
+ */
492
+ findSessionIdsByWorkspaceProfile(profileName, limit = 20, options = {}) {
493
+ const includeArchived = options.includeArchived !== false;
494
+ const target = typeof profileName === "string" ? profileName.trim() : "";
495
+ if (!target || target.length > 64 || limit <= 0)
496
+ return [];
497
+ const cap = Math.max(1, Math.min(100, Math.floor(limit)));
498
+ let entries;
499
+ try {
500
+ entries = readdirSync(this.sessionsDir, { withFileTypes: true });
501
+ }
502
+ catch {
503
+ return [];
504
+ }
505
+ const matches = [];
506
+ for (const entry of entries) {
507
+ if (matches.length >= cap)
508
+ break;
509
+ if (!entry.isDirectory() ||
510
+ entry.name.startsWith(".pending-") ||
511
+ entry.name.startsWith("qchat-")) {
512
+ continue;
513
+ }
514
+ try {
515
+ assertSafeSessionId(entry.name);
516
+ const stateFile = join(this.sessionsDir, entry.name, "state.json");
517
+ const info = lstatSync(stateFile);
518
+ if (info.isSymbolicLink() || !info.isFile() || info.size > 1024 * 1024)
519
+ continue;
520
+ const state = JSON.parse(readFileSync(stateFile, "utf-8"));
521
+ if (state.workspaceProfile !== target)
522
+ continue;
523
+ if (!includeArchived && typeof state.archivedAt === "number")
524
+ continue;
525
+ matches.push(entry.name);
526
+ }
527
+ catch {
528
+ // Corrupt or unsafe Sessions are isolated from the deletion decision.
529
+ }
530
+ }
531
+ return matches.sort();
532
+ }
533
+ /** @deprecated Use readSessionMainRoot; retained for public API compatibility. */
192
534
  readCwd(sessionId) {
535
+ return this.readSessionMainRoot(sessionId);
536
+ }
537
+ /** Disk-only direct-parent ACL metadata. Undefined means unprovable/corrupt. */
538
+ readParentSessionId(sessionId) {
193
539
  try {
194
540
  assertSafeSessionId(sessionId);
195
541
  }
@@ -201,7 +547,9 @@ export class SessionManager {
201
547
  return undefined;
202
548
  try {
203
549
  const state = JSON.parse(readFileSync(stateFile, "utf-8"));
204
- return typeof state.cwd === "string" && state.cwd.length > 0 ? state.cwd : undefined;
550
+ return state.parentSessionId === null || typeof state.parentSessionId === "string"
551
+ ? state.parentSessionId
552
+ : undefined;
205
553
  }
206
554
  catch {
207
555
  return undefined;
@@ -226,9 +574,8 @@ export class SessionManager {
226
574
  const state = JSON.parse(readFileSync(stateFile, "utf-8"));
227
575
  if (isSessionWorkspace(state.workspace))
228
576
  return state.workspace;
229
- return typeof state.cwd === "string" && state.cwd.length > 0
230
- ? { root: state.cwd, kind: "main" }
231
- : undefined;
577
+ const mainRoot = sessionMainRoot(state);
578
+ return mainRoot ? { root: mainRoot, kind: "main" } : undefined;
232
579
  }
233
580
  catch {
234
581
  return undefined;
@@ -244,19 +591,30 @@ export class SessionManager {
244
591
  if (!isSessionWorkspace(workspace)) {
245
592
  throw new SessionError(`invalid workspace for session ${sessionId}`);
246
593
  }
247
- const stateFile = join(this.sessionsDir, sessionId, "state.json");
248
- if (!existsSync(stateFile)) {
249
- throw new SessionError(`Session state file not found: ${sessionId}`);
594
+ return this.updateSessionState(sessionId, { workspace });
595
+ }
596
+ /** Read the durable archival timestamp; undefined = not archived / unprovable. */
597
+ readSessionArchivedAt(sessionId) {
598
+ try {
599
+ assertSafeSessionId(sessionId);
250
600
  }
251
- let state;
601
+ catch {
602
+ return undefined;
603
+ }
604
+ const stateFile = join(this.sessionsDir, sessionId, "state.json");
605
+ if (!existsSync(stateFile))
606
+ return undefined;
252
607
  try {
253
- state = JSON.parse(readFileSync(stateFile, "utf-8"));
608
+ const state = JSON.parse(readFileSync(stateFile, "utf-8"));
609
+ return typeof state.archivedAt === "number" ? state.archivedAt : undefined;
254
610
  }
255
- catch (err) {
256
- throw new SessionError(`Session state is corrupt for ${sessionId}: ${err instanceof Error ? err.message : String(err)}`);
611
+ catch {
612
+ return undefined;
257
613
  }
258
- state.workspace = workspace;
259
- this.saveState(state);
614
+ }
615
+ /** Set (number) or clear (undefined) the durable archival marker. */
616
+ setSessionArchived(sessionId, archivedAt) {
617
+ return this.updateSessionState(sessionId, { archivedAt });
260
618
  }
261
619
  recordWorkspaceHandoff(sessionId, from, to) {
262
620
  assertSafeSessionId(sessionId);
@@ -298,9 +656,8 @@ export class SessionManager {
298
656
  catch (err) {
299
657
  throw new SessionError(`Session state is corrupt for ${sessionId}: ${err instanceof Error ? err.message : String(err)}`);
300
658
  }
301
- const legacyMain = typeof state.cwd === "string" && state.cwd.length > 0
302
- ? { root: state.cwd, kind: "main" }
303
- : undefined;
659
+ const mainRoot = sessionMainRoot(state);
660
+ const legacyMain = mainRoot ? { root: mainRoot, kind: "main" } : undefined;
304
661
  const workspace = isSessionWorkspace(state.workspace) ? state.workspace : legacyMain;
305
662
  if (!workspace) {
306
663
  throw new SessionError(`Session ${sessionId} has no recoverable cwd`);
@@ -313,16 +670,28 @@ export class SessionManager {
313
670
  reason: isSessionWorkspace(state.workspace) ? "main" : "legacy",
314
671
  };
315
672
  }
316
- const invalidWorktreeReason = await validateResumeWorktreeRoot(workspace.root);
673
+ if (!this.workspaceCapability) {
674
+ return {
675
+ ok: false,
676
+ cwd: mainRoot ?? workspace.root,
677
+ workspace,
678
+ reason: "workspace_capability_unavailable",
679
+ message: `Session ${sessionId} uses a product workspace, but this host did not install ` +
680
+ `the matching workspace capability. Install it before resuming this session.`,
681
+ };
682
+ }
683
+ const invalidWorktreeReason = await validateResumeWorktreeRoot(workspace.root, this.workspaceCapability);
317
684
  if (!invalidWorktreeReason) {
318
685
  return { ok: true, cwd: workspace.root, workspace, reason: "worktree" };
319
686
  }
320
687
  const branch = workspace.worktree?.branch;
321
- const mainRoot = typeof state.cwd === "string" && state.cwd.length > 0 ? state.cwd : workspace.root;
322
- if (branch && existsSync(mainRoot) && (await branchExists(mainRoot, branch))) {
688
+ const fallbackMainRoot = mainRoot ?? workspace.root;
689
+ if (branch &&
690
+ existsSync(fallbackMainRoot) &&
691
+ (await this.workspaceCapability.branchExists(fallbackMainRoot, branch))) {
323
692
  return {
324
693
  ok: false,
325
- cwd: mainRoot,
694
+ cwd: fallbackMainRoot,
326
695
  workspace,
327
696
  reason: "worktree_missing_branch_exists",
328
697
  message: `Session ${sessionId} is bound to worktree ${workspace.root}, but that directory ` +
@@ -331,23 +700,23 @@ export class SessionManager {
331
700
  `back to main explicitly.`,
332
701
  };
333
702
  }
334
- const fallback = { root: mainRoot, kind: "main" };
703
+ const fallback = { root: fallbackMainRoot, kind: "main" };
335
704
  state.workspace = fallback;
336
- this.saveState(state);
705
+ state.stateRevision = this.updateSessionState(sessionId, { workspace: fallback });
337
706
  return {
338
707
  ok: true,
339
- cwd: mainRoot,
708
+ cwd: fallbackMainRoot,
340
709
  workspace: fallback,
341
710
  reason: "worktree_missing_branch_gone",
342
711
  message: `Session ${sessionId} was bound to worktree ${workspace.root}, but that directory ` +
343
712
  `${invalidWorktreeReason}${branch ? ` and branch ${branch} is gone` : ""}; fell back to main ` +
344
- `${mainRoot}. Re-run the request if you want to continue there.`,
713
+ `${fallbackMainRoot}. Re-run the request if you want to continue there.`,
345
714
  };
346
715
  }
347
716
  /**
348
717
  * Cheap "does this session have a persisted goal?" probe — reads only
349
- * state.json, NOT the transcript (like readCwd). A persistent goal lives ONLY
350
- * in state.activeGoal; it is never appended to the transcript as an event, so
718
+ * state.json, NOT the transcript (like readSessionMainRoot). A persistent goal lives in
719
+ * the canonical goalLifecycle union; it is never appended to the transcript as an event, so
351
720
  * a session rebuilt from disk (e.g. localStorage wiped) can't recover the
352
721
  * goal from its messages. The desktop host calls this on session load to
353
722
  * re-surface the active-goal block + its Cancel button, which would otherwise
@@ -366,10 +735,18 @@ export class SessionManager {
366
735
  if (!existsSync(stateFile))
367
736
  return undefined;
368
737
  try {
369
- const state = JSON.parse(readFileSync(stateFile, "utf-8"));
370
- return isSameGoalInstance(state.activeGoal, state.goalTerminal)
371
- ? undefined
372
- : state.activeGoal;
738
+ const state = hydrateGoalLifecycle(JSON.parse(readFileSync(stateFile, "utf-8")));
739
+ const lifecycle = state.goalLifecycle;
740
+ if (!lifecycle || lifecycle.phase === "terminal")
741
+ return undefined;
742
+ const goal = goalConfigFromLifecycle(lifecycle);
743
+ // Expose a concrete version even for legacy state.json so UI delete/edit
744
+ // operations can send an expected fence instead of acting unconditionally.
745
+ return {
746
+ ...goal,
747
+ goalId: goal.goalId ?? deriveLegacyGoalId(sessionId, goal),
748
+ revision: goal.revision ?? 1,
749
+ };
373
750
  }
374
751
  catch {
375
752
  return undefined;
@@ -377,8 +754,9 @@ export class SessionManager {
377
754
  }
378
755
  /**
379
756
  * Disk-only "wipe this session's persistent goal" — the counterpart to
380
- * readActiveGoal. Reads state.json, removes state.activeGoal, and rewrites it
381
- * atomically (via saveState). Returns true only if a goal was actually
757
+ * readActiveGoal. Reads state.json, transitions the matching goalLifecycle
758
+ * to terminal(user_cleared), and rewrites it through the field-level CAS.
759
+ * Returns true only if a goal was actually
382
760
  * present (idempotent — clearing a session with no goal is a no-op returning
383
761
  * false). Never throws on an unknown / malformed / traversal-shaped id
384
762
  * (returns false), matching readActiveGoal's tolerance.
@@ -391,28 +769,107 @@ export class SessionManager {
391
769
  * Engine.clearGoal also delegates its disk write here so the wipe logic lives
392
770
  * in exactly one place.
393
771
  */
394
- clearActiveGoal(sessionId) {
772
+ clearActiveGoal(sessionId, expected) {
395
773
  try {
396
774
  assertSafeSessionId(sessionId);
397
775
  }
398
776
  catch {
399
777
  return false;
400
778
  }
401
- const stateFile = join(this.sessionsDir, sessionId, "state.json");
402
- if (!existsSync(stateFile))
403
- return false;
404
- let state;
779
+ for (let attempt = 0; attempt <= SESSION_STATE_LOCK_RETRY_DELAYS_MS.length; attempt++) {
780
+ let state;
781
+ try {
782
+ state = this.readPersistedState(sessionId);
783
+ }
784
+ catch {
785
+ return false;
786
+ }
787
+ const lifecycle = state.goalLifecycle;
788
+ if (!lifecycle || !isGoalLifecycleCurrent(lifecycle))
789
+ return false;
790
+ const clearedGoal = goalConfigFromLifecycle(lifecycle);
791
+ const revision = lifecycle.revision;
792
+ if (expected?.goalId !== undefined && clearedGoal.goalId !== expected.goalId)
793
+ return false;
794
+ if (expected?.revision !== undefined && revision !== expected.revision)
795
+ return false;
796
+ const terminal = terminateGoalLifecycle(lifecycle, "user_cleared");
797
+ if (!terminal)
798
+ return false;
799
+ state.goalLifecycle = terminal;
800
+ hydrateGoalLifecycle(state);
801
+ const result = this.saveStateAttempt(state);
802
+ if (result.ok)
803
+ return true;
804
+ if (result.reason !== "revision_conflict")
805
+ return false;
806
+ }
807
+ return false;
808
+ }
809
+ /**
810
+ * Edit or pause/resume the currently active goal with an identity-checked,
811
+ * field-level CAS. The goalId is preserved: editing the objective changes
812
+ * the same user-owned goal rather than terminally cancelling it and arming a
813
+ * replacement. A changed objective receives a fresh deadline anchor.
814
+ *
815
+ * Returns undefined when the session/goal is absent or the patch is invalid.
816
+ * The returned state lets a live Engine rebase its in-memory bundle after
817
+ * this control operation.
818
+ */
819
+ updateActiveGoal(sessionId, patch) {
405
820
  try {
406
- state = JSON.parse(readFileSync(stateFile, "utf-8"));
821
+ assertSafeSessionId(sessionId);
407
822
  }
408
823
  catch {
409
- return false;
824
+ return undefined;
410
825
  }
411
- if (state.activeGoal === undefined)
412
- return false;
413
- state.activeGoal = undefined;
414
- this.saveState(state);
415
- return true;
826
+ if (patch.objective === undefined && patch.paused === undefined)
827
+ return undefined;
828
+ if (patch.objective !== undefined && !patch.objective.trim())
829
+ return undefined;
830
+ if (patch.paused !== undefined && typeof patch.paused !== "boolean")
831
+ return undefined;
832
+ let expectedGoalId = patch.expectedGoalId;
833
+ let expectedRevision = patch.expectedRevision;
834
+ for (let attempt = 0; attempt <= SESSION_STATE_LOCK_RETRY_DELAYS_MS.length; attempt++) {
835
+ let state;
836
+ try {
837
+ state = this.readPersistedState(sessionId);
838
+ }
839
+ catch {
840
+ return undefined;
841
+ }
842
+ const lifecycle = state.goalLifecycle;
843
+ if (!lifecycle || !isGoalLifecycleCurrent(lifecycle))
844
+ return undefined;
845
+ const current = goalConfigFromLifecycle(lifecycle);
846
+ const currentRevision = lifecycle.revision;
847
+ expectedGoalId ??= current.goalId;
848
+ expectedRevision ??= currentRevision;
849
+ if (current.goalId !== expectedGoalId || currentRevision !== expectedRevision) {
850
+ return undefined;
851
+ }
852
+ const objective = patch.objective?.trim() ?? current.objective;
853
+ const objectiveChanged = objective !== current.objective;
854
+ const goal = {
855
+ ...current,
856
+ objective,
857
+ revision: currentRevision + 1,
858
+ ...(objectiveChanged ? { setAtMs: Date.now() } : {}),
859
+ };
860
+ if (patch.paused === true)
861
+ goal.paused = true;
862
+ else if (patch.paused === false)
863
+ delete goal.paused;
864
+ state.goalLifecycle = createGoalLifecycle(goal, goal.paused === true ? "paused" : "active");
865
+ hydrateGoalLifecycle(state);
866
+ const result = this.saveStateAttempt(state);
867
+ if (result.ok)
868
+ return { goal, stateRevision: state.stateRevision, state };
869
+ if (result.reason !== "revision_conflict")
870
+ return undefined;
871
+ }
872
+ return undefined;
416
873
  }
417
874
  resume(sessionId) {
418
875
  assertSafeSessionId(sessionId);
@@ -426,7 +883,7 @@ export class SessionManager {
426
883
  }
427
884
  let state;
428
885
  try {
429
- state = JSON.parse(readFileSync(stateFile, "utf-8"));
886
+ state = hydrateGoalLifecycle(JSON.parse(readFileSync(stateFile, "utf-8")));
430
887
  }
431
888
  catch (err) {
432
889
  // A corrupt state.json (external tampering, disk corruption, or a crash
@@ -436,73 +893,586 @@ export class SessionManager {
436
893
  }
437
894
  const transcriptFile = join(sessionDir, "transcript.jsonl");
438
895
  const transcript = Transcript.loadFromFile(transcriptFile);
896
+ state.kind = normalizedSessionKind(state.kind);
439
897
  state.status = "active";
898
+ delete state.lastCompletionKind;
440
899
  Object.assign(state, normalizeCumulativeUsageCounters(state, state.tokenUsage));
441
900
  return { state, transcript };
442
901
  }
443
- saveState(state) {
902
+ /**
903
+ * Return the exact persistence directory owned by this manager.
904
+ *
905
+ * Cross-session sidecars (for example versioned handoffs) use this to stay
906
+ * inside an identity/custom-data-root boundary instead of falling back to
907
+ * the process-global default sessions directory.
908
+ */
909
+ getStorageDir() {
910
+ return this.sessionsDir;
911
+ }
912
+ /**
913
+ * Read one durable session state without changing its lifecycle status.
914
+ * `resume()` intentionally marks the returned in-memory state active; host
915
+ * coordination code needs a read-only snapshot for completed cursors and
916
+ * workspace/profile metadata.
917
+ */
918
+ readSessionState(sessionId) {
919
+ try {
920
+ assertSafeSessionId(sessionId);
921
+ return structuredClone(this.readPersistedState(sessionId));
922
+ }
923
+ catch {
924
+ return undefined;
925
+ }
926
+ }
927
+ /**
928
+ * Merge a field-level state update into the latest persisted snapshot.
929
+ *
930
+ * Unlike saveState(), callers do not supply a potentially stale whole-state
931
+ * object. saveState serializes the read/revision-check/write section with the
932
+ * per-session lock and returns the new persisted revision to the caller.
933
+ */
934
+ updateSessionState(sessionId, partial) {
935
+ assertSafeSessionId(sessionId);
936
+ for (let attempt = 0; attempt <= SESSION_STATE_LOCK_RETRY_DELAYS_MS.length; attempt++) {
937
+ const state = this.readPersistedState(sessionId);
938
+ if (partial.kind !== undefined &&
939
+ normalizedSessionKind(partial.kind) !== normalizedSessionKind(state.kind)) {
940
+ throw new SessionError(`Session kind is immutable for ${sessionId}`);
941
+ }
942
+ Object.assign(state, partial);
943
+ state.sessionId = sessionId;
944
+ const result = this.saveStateAttempt(state);
945
+ if (result.ok)
946
+ return state.stateRevision;
947
+ if (result.reason === "generation_conflict") {
948
+ throw new SessionError(`Session generation conflict for ${sessionId}`);
949
+ }
950
+ if (result.reason === "lock_conflict") {
951
+ throw new SessionError(`Session state lock contention for ${sessionId}`);
952
+ }
953
+ if (result.reason === "kind_conflict") {
954
+ throw new SessionError(`Session kind is immutable for ${sessionId}`);
955
+ }
956
+ // A field-level update is explicitly authorized to merge into the newest
957
+ // snapshot. Re-read it on the next iteration instead of treating its CAS
958
+ // miss like a lock collision.
959
+ }
960
+ throw new SessionError(`Session state revision conflict for ${sessionId}`);
961
+ }
962
+ /**
963
+ * Atomically add one billed auxiliary request to the latest source state.
964
+ * This field-level read/add/write avoids publishing a detached stale state
965
+ * snapshot and accounts prompt, completion, total, cache, and cost together.
966
+ */
967
+ recordAuxiliaryUsage(sessionId, usage, costState) {
968
+ assertSafeSessionId(sessionId);
969
+ for (let attempt = 0; attempt <= SESSION_STATE_LOCK_RETRY_DELAYS_MS.length; attempt++) {
970
+ const state = this.readPersistedState(sessionId);
971
+ Object.assign(state, normalizeCumulativeUsageCounters(state, state.tokenUsage));
972
+ Object.assign(state, addCumulativeUsage(state, usage));
973
+ state.tokenUsage = addTokenUsage(state.tokenUsage, usage);
974
+ if (costState !== undefined)
975
+ state.costState = costState;
976
+ const result = this.saveStateAttempt(state);
977
+ if (result.ok)
978
+ return;
979
+ if (result.reason === "generation_conflict") {
980
+ throw new SessionError(`Session generation conflict for ${sessionId}`);
981
+ }
982
+ if (result.reason === "lock_conflict") {
983
+ throw new SessionError(`Session state lock contention for ${sessionId}`);
984
+ }
985
+ // Recompute the delta from the latest counters after a CAS miss so the
986
+ // auxiliary usage is added exactly once without overwriting another writer.
987
+ }
988
+ throw new SessionError(`Session state revision conflict for ${sessionId}`);
989
+ }
990
+ /** @deprecated Compatibility fixture writer; runtime code must use domain updates. */
991
+ saveState(state, generation) {
992
+ adoptCompatibilityGoalMutation(state);
993
+ return this.saveStateAttempt(state, generation).ok;
994
+ }
995
+ /**
996
+ * Persist one goal terminal transition without allowing a revision race to
997
+ * resurrect that goal. This is always a Goal-domain update: read the newest
998
+ * snapshot, transition only the matching lifecycle identity, then commit. A detached run
999
+ * snapshot is never used as the write candidate,
1000
+ * so a successful terminal transition cannot publish stale summary/workspace
1001
+ * or accounting fields. Lock/generation conflicts remain distinct failures.
1002
+ */
1003
+ saveGoalTerminal(state, goal, reason) {
1004
+ return this.saveGoalTerminalOutcome(state, goal, reason) !== "failed";
1005
+ }
1006
+ /**
1007
+ * Persist a terminal transition and distinguish a durable commit from a stale
1008
+ * run whose Goal identity has already been replaced. Callers may publish a
1009
+ * terminal event only for `persisted`.
1010
+ */
1011
+ saveGoalTerminalOutcome(state, goal, reason) {
1012
+ if (!goal)
1013
+ return "failed";
1014
+ const terminalGoal = {
1015
+ ...goal,
1016
+ goalId: goal.goalId ?? deriveLegacyGoalId(state.sessionId, goal),
1017
+ revision: goal.revision ?? 1,
1018
+ };
1019
+ for (let attempt = 0; attempt <= SESSION_STATE_LOCK_RETRY_DELAYS_MS.length; attempt++) {
1020
+ const latest = this.readPersistedState(state.sessionId);
1021
+ const lifecycle = latest.goalLifecycle;
1022
+ if (!lifecycle)
1023
+ return "failed";
1024
+ if (lifecycle.goalId !== terminalGoal.goalId ||
1025
+ lifecycle.revision !== (terminalGoal.revision ?? 1)) {
1026
+ // A newer replacement/edit already owns the canonical slot. The stale
1027
+ // run is obsolete, but must not mutate that newer goal.
1028
+ this.rebaseLiveState(state, latest);
1029
+ return "obsolete";
1030
+ }
1031
+ if (lifecycle.phase !== "terminal") {
1032
+ latest.goalLifecycle = terminateGoalLifecycle(lifecycle, lifecycleTerminalReason(reason));
1033
+ hydrateGoalLifecycle(latest);
1034
+ }
1035
+ const result = this.saveStateAttempt(latest);
1036
+ if (result.ok) {
1037
+ this.rebaseLiveState(state, latest);
1038
+ return "persisted";
1039
+ }
1040
+ if (result.reason !== "revision_conflict")
1041
+ return "failed";
1042
+ }
1043
+ return "failed";
1044
+ }
1045
+ /**
1046
+ * Arm or migrate one active goal as a Goal-domain update. The newest state is
1047
+ * always the write base, so unrelated fields written by another host survive.
1048
+ * Replacement is explicit and terminally closes the latest active identity;
1049
+ * a non-replacement write may only normalize the same legacy/current goal.
1050
+ */
1051
+ saveActiveGoal(state, goal, options = {}) {
1052
+ const armedGoal = {
1053
+ ...goal,
1054
+ goalId: goal.goalId ?? deriveLegacyGoalId(state.sessionId, goal),
1055
+ revision: goal.revision ?? 1,
1056
+ };
1057
+ for (let attempt = 0; attempt <= SESSION_STATE_LOCK_RETRY_DELAYS_MS.length; attempt++) {
1058
+ const latest = this.readPersistedState(state.sessionId);
1059
+ const current = latest.goalLifecycle;
1060
+ if (current) {
1061
+ const sameIdentity = current.goalId === armedGoal.goalId;
1062
+ const sameRevision = current.revision === (armedGoal.revision ?? 1);
1063
+ if (current.phase === "terminal" && sameIdentity && sameRevision)
1064
+ return false;
1065
+ if (current.phase !== "terminal" && !options.replaceCurrent && !sameIdentity)
1066
+ return false;
1067
+ }
1068
+ if (current?.phase === "waiting" &&
1069
+ current.goalId === armedGoal.goalId &&
1070
+ current.revision === (armedGoal.revision ?? 1)) {
1071
+ latest.goalLifecycle = armGoalLifecycle(current) ?? current;
1072
+ }
1073
+ else {
1074
+ latest.goalLifecycle = createGoalLifecycle(armedGoal, armedGoal.paused === true ? "paused" : "active");
1075
+ }
1076
+ hydrateGoalLifecycle(latest);
1077
+ const result = this.saveStateAttempt(latest);
1078
+ if (result.ok) {
1079
+ this.rebaseLiveState(state, latest);
1080
+ return true;
1081
+ }
1082
+ if (result.reason !== "revision_conflict")
1083
+ return false;
1084
+ }
1085
+ return false;
1086
+ }
1087
+ /** Persist active → waiting only for the expected concrete Goal version. */
1088
+ markGoalWaiting(state, goal) {
1089
+ const expectedGoalId = goal.goalId ?? deriveLegacyGoalId(state.sessionId, goal);
1090
+ const expectedRevision = Math.max(1, Math.floor(goal.revision ?? 1));
1091
+ for (let attempt = 0; attempt <= SESSION_STATE_LOCK_RETRY_DELAYS_MS.length; attempt++) {
1092
+ const latest = this.readPersistedState(state.sessionId);
1093
+ const lifecycle = latest.goalLifecycle;
1094
+ if (!lifecycle)
1095
+ return false;
1096
+ if (lifecycle.goalId !== expectedGoalId || lifecycle.revision !== expectedRevision) {
1097
+ this.rebaseLiveState(state, latest);
1098
+ return false;
1099
+ }
1100
+ if (lifecycle.phase === "waiting") {
1101
+ this.rebaseLiveState(state, latest);
1102
+ return true;
1103
+ }
1104
+ const waiting = waitGoalLifecycle(lifecycle);
1105
+ if (!waiting)
1106
+ return false;
1107
+ latest.goalLifecycle = waiting;
1108
+ hydrateGoalLifecycle(latest);
1109
+ const result = this.saveStateAttempt(latest);
1110
+ if (result.ok) {
1111
+ this.rebaseLiveState(state, latest);
1112
+ return true;
1113
+ }
1114
+ if (result.reason !== "revision_conflict")
1115
+ return false;
1116
+ }
1117
+ return false;
1118
+ }
1119
+ /** Drop a matching active goal only when its terminal identity is durable. */
1120
+ clearTerminatedActiveGoal(state, goal) {
1121
+ for (let attempt = 0; attempt <= SESSION_STATE_LOCK_RETRY_DELAYS_MS.length; attempt++) {
1122
+ const latest = this.readPersistedState(state.sessionId);
1123
+ const expectedGoalId = goal.goalId ?? deriveLegacyGoalId(state.sessionId, goal);
1124
+ const lifecycle = latest.goalLifecycle;
1125
+ if (!lifecycle ||
1126
+ lifecycle.goalId !== expectedGoalId ||
1127
+ lifecycle.revision !== Math.max(1, Math.floor(goal.revision ?? 1))) {
1128
+ this.rebaseLiveState(state, latest);
1129
+ return true;
1130
+ }
1131
+ if (lifecycle.phase !== "terminal")
1132
+ return false;
1133
+ this.rebaseLiveState(state, latest);
1134
+ return true;
1135
+ }
1136
+ return false;
1137
+ }
1138
+ /**
1139
+ * Commit only the fields owned by a live run. The detached `state` object is
1140
+ * used solely as a live-view target for the returned revision; it is never
1141
+ * serialized wholesale. This prevents a Goal/workspace/title domain write
1142
+ * from authorizing a later stale metadata overwrite.
1143
+ */
1144
+ saveStateOrUpdateFields(state, partial) {
1145
+ try {
1146
+ const stateRevision = this.updateSessionState(state.sessionId, partial);
1147
+ Object.assign(state, partial, { stateRevision });
1148
+ return true;
1149
+ }
1150
+ catch {
1151
+ return false;
1152
+ }
1153
+ }
1154
+ saveStateAttempt(state, generation) {
444
1155
  // state.sessionId could come from a deserialized state.json that was
445
1156
  // tampered with on disk. Validate before joining.
446
1157
  assertSafeSessionId(state.sessionId);
1158
+ try {
1159
+ hydrateGoalLifecycle(state);
1160
+ }
1161
+ catch {
1162
+ return { ok: false, reason: "goal_schema_conflict" };
1163
+ }
1164
+ const writerGeneration = generation ?? this.registeredCloseEpochs.get(state.sessionId);
1165
+ if (writerGeneration !== undefined &&
1166
+ (currentSessionCloseEpochs.get(this.generationKey(state.sessionId)) ?? 0) !== writerGeneration) {
1167
+ return { ok: false, reason: "generation_conflict" };
1168
+ }
447
1169
  const sessionDir = join(this.sessionsDir, state.sessionId);
448
1170
  mkdirSync(sessionDir, { recursive: true });
449
- // Atomic write: stage to .tmp, then rename. Protects against two processes
450
- // clobbering each other's state.json mid-write.
451
1171
  const target = join(sessionDir, "state.json");
452
- // Preserve the newest goal tombstone across whole-state writers. If an old
453
- // detached bundle still carries the tombstoned goal, drop it before write;
454
- // when disk already contains a newer replacement goal, retain that goal as
455
- // well instead of letting the stale bundle erase it.
456
- let persisted;
457
- if (existsSync(target)) {
1172
+ // proper-lockfile uses an atomic mkdir lock plus an mtime lease. It
1173
+ // recovers crash-orphaned locks after `stale` and refreshes live locks so a
1174
+ // slow writer is not stolen. Short genuine contention gets bounded retry.
1175
+ const release = this.acquireStateLock(target);
1176
+ if (!release)
1177
+ return { ok: false, reason: "lock_conflict" };
1178
+ try {
1179
+ let persisted;
1180
+ if (existsSync(target)) {
1181
+ try {
1182
+ persisted = hydrateGoalLifecycle(JSON.parse(readFileSync(target, "utf-8")));
1183
+ }
1184
+ catch {
1185
+ return { ok: false, reason: "goal_schema_conflict" };
1186
+ }
1187
+ }
1188
+ const incomingKind = normalizedSessionKind(state.kind);
1189
+ const persistedKind = persisted ? normalizedSessionKind(persisted.kind) : incomingKind;
1190
+ if (persisted && incomingKind !== persistedKind) {
1191
+ return { ok: false, reason: "kind_conflict" };
1192
+ }
1193
+ state.kind = persistedKind;
1194
+ const persistedRevision = persisted?.stateRevision;
1195
+ const incomingRevision = state.stateRevision;
1196
+ const revisionsMatch = persisted === undefined ||
1197
+ (persistedRevision === undefined && incomingRevision === undefined) ||
1198
+ (typeof persistedRevision === "number" && incomingRevision === persistedRevision);
1199
+ if (!revisionsMatch)
1200
+ return { ok: false, reason: "revision_conflict" };
1201
+ // A title can be generated by a field-level writer while this bundle is
1202
+ // live. Preserve it when the incoming object never owned the field.
1203
+ if (persisted?.title !== undefined && !("title" in state)) {
1204
+ state.title = persisted.title;
1205
+ }
1206
+ state.stateRevision = (persistedRevision ?? incomingRevision ?? 0) + 1;
1207
+ const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
1208
+ writeFileSync(tmp, JSON.stringify(stateForPersistence(state), null, 2), "utf-8");
1209
+ renameSync(tmp, target);
1210
+ return { ok: true };
1211
+ }
1212
+ finally {
458
1213
  try {
459
- persisted = JSON.parse(readFileSync(target, "utf-8"));
1214
+ release();
460
1215
  }
461
1216
  catch {
462
- // The atomic writer should make this rare. Preserve the existing
463
- // behavior and overwrite malformed state with the caller's snapshot.
1217
+ // A compromised/reaped lease must not mask the persistence result.
464
1218
  }
465
1219
  }
466
- const terminal = newestGoalTerminal(state.goalTerminal, persisted?.goalTerminal);
467
- if (terminal)
468
- state.goalTerminal = terminal;
469
- if (terminal && isSameGoalInstance(state.activeGoal, terminal)) {
470
- const diskGoal = persisted?.activeGoal;
471
- state.activeGoal = diskGoal && !isSameGoalInstance(diskGoal, terminal) ? diskGoal : undefined;
1220
+ }
1221
+ acquireStateLock(target) {
1222
+ const lockPath = `${target}.lock`;
1223
+ for (let attempt = 0; attempt <= SESSION_STATE_LOCK_RETRY_DELAYS_MS.length; attempt++) {
1224
+ if (!this.prepareLegacyStateLock(lockPath)) {
1225
+ const delay = SESSION_STATE_LOCK_RETRY_DELAYS_MS[attempt];
1226
+ if (delay === undefined)
1227
+ return undefined;
1228
+ sleepSync(delay);
1229
+ continue;
1230
+ }
1231
+ try {
1232
+ return lockSync(target, {
1233
+ stale: SESSION_STATE_LOCK_STALE_MS,
1234
+ update: SESSION_STATE_LOCK_STALE_MS / 2,
1235
+ retries: 0,
1236
+ realpath: false,
1237
+ });
1238
+ }
1239
+ catch (err) {
1240
+ const code = err.code;
1241
+ if (code !== "ELOCKED" && code !== "EEXIST")
1242
+ throw err;
1243
+ const delay = SESSION_STATE_LOCK_RETRY_DELAYS_MS[attempt];
1244
+ if (delay === undefined)
1245
+ return undefined;
1246
+ sleepSync(delay);
1247
+ }
472
1248
  }
473
- const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
474
- writeFileSync(tmp, JSON.stringify(state, null, 2), "utf-8");
475
- renameSync(tmp, target);
1249
+ return undefined;
476
1250
  }
477
- /**
478
- * Fork a session from a specific point.
479
- * Creates a new session that shares events up to the given turn,
480
- * then diverges independently.
481
- */
482
- fork(sourceSessionId, forkAtTurn) {
1251
+ /** Recover only the old implementation's regular-file orphan lock. */
1252
+ prepareLegacyStateLock(lockPath) {
1253
+ if (!existsSync(lockPath))
1254
+ return true;
1255
+ let stat;
1256
+ try {
1257
+ stat = lstatSync(lockPath);
1258
+ }
1259
+ catch {
1260
+ return true;
1261
+ }
1262
+ if (stat.isDirectory())
1263
+ return true;
1264
+ if (Date.now() - stat.mtimeMs <= SESSION_STATE_LOCK_STALE_MS)
1265
+ return false;
1266
+ try {
1267
+ rmSync(lockPath, { force: true });
1268
+ return true;
1269
+ }
1270
+ catch {
1271
+ return false;
1272
+ }
1273
+ }
1274
+ readPersistedState(sessionId) {
1275
+ const stateFile = join(this.sessionsDir, sessionId, "state.json");
1276
+ if (!existsSync(stateFile)) {
1277
+ throw new SessionError(`Session state file not found: ${sessionId}`);
1278
+ }
1279
+ try {
1280
+ return hydrateGoalLifecycle(JSON.parse(readFileSync(stateFile, "utf-8")));
1281
+ }
1282
+ catch (err) {
1283
+ throw new SessionError(`Session state is corrupt for ${sessionId}: ${err instanceof Error ? err.message : String(err)}`);
1284
+ }
1285
+ }
1286
+ rebaseLiveState(live, persisted) {
1287
+ for (const key of Object.keys(live)) {
1288
+ if (!(key in persisted))
1289
+ delete live[key];
1290
+ }
1291
+ Object.assign(live, persisted);
1292
+ }
1293
+ generationKey(sessionId) {
1294
+ return `${this.sessionsDir}\0${sessionId}`;
1295
+ }
1296
+ /** Create an independent, top-level session from a frozen event cursor. */
1297
+ fork(sourceSessionId, options = {}) {
1298
+ assertSafeSessionId(sourceSessionId);
1299
+ if (options.targetSessionId !== undefined)
1300
+ assertSafeSessionId(options.targetSessionId);
1301
+ if (options.snapshotMode === "completed" && options.throughEventId !== undefined) {
1302
+ throw new SessionError(`Completed snapshot cannot use an explicit fork cursor`);
1303
+ }
1304
+ const snapshot = this.readForkSnapshot(sourceSessionId, options.throughEventId, options.snapshotMode ?? "tail");
1305
+ const targetSessionId = options.targetSessionId ?? nanoid(16);
1306
+ const createdAt = Date.now();
1307
+ const lineage = {
1308
+ sessionId: sourceSessionId,
1309
+ mode: "full",
1310
+ fromEventId: snapshot.copiedEvents[0]?.id,
1311
+ throughEventId: snapshot.copiedEvents[snapshot.copiedEvents.length - 1]?.id,
1312
+ sourceEventCount: snapshot.copiedEvents.length,
1313
+ createdAt,
1314
+ };
1315
+ const state = buildForkState(snapshot.sourceState, targetSessionId, lineage, createdAt);
1316
+ if (options.ephemeral === true)
1317
+ state.ephemeral = true;
1318
+ const events = buildForkTranscript(snapshot.copiedEvents, state);
1319
+ const bundle = this.publishSessionAtomically(targetSessionId, state, events);
1320
+ return { bundle, lineage, copiedEventCount: snapshot.copiedEvents.length };
1321
+ }
1322
+ /** Freeze and validate an inclusive source range before any model call. */
1323
+ selectContextPackage(sourceSessionId, range) {
1324
+ assertSafeSessionId(sourceSessionId);
1325
+ const transcriptFile = join(this.sessionsDir, sourceSessionId, "transcript.jsonl");
1326
+ const stateFile = join(this.sessionsDir, sourceSessionId, "state.json");
1327
+ if (!existsSync(stateFile))
1328
+ throw new SessionError(`Session not found: ${sourceSessionId}`);
1329
+ const parsed = Transcript.readEvents(transcriptFile);
1330
+ if (parsed.malformedLineCount > 0) {
1331
+ throw new SessionError(`Session transcript is malformed for ${sourceSessionId}: ${parsed.malformedLineCount} invalid line(s)`);
1332
+ }
1333
+ return Transcript.selectContextRange(parsed.events, range);
1334
+ }
1335
+ /** Publish a summary-only top-level fork after summarization has succeeded. */
1336
+ createSummaryFork(sourceSessionId, options) {
1337
+ assertSafeSessionId(sourceSessionId);
1338
+ if (options.targetSessionId !== undefined)
1339
+ assertSafeSessionId(options.targetSessionId);
1340
+ if (!options.fromEventId || !options.toEventId) {
1341
+ throw new SessionError("Summary fork requires a closed source event range");
1342
+ }
1343
+ if (!options.summary.trim())
1344
+ throw new SessionError("Summary fork requires a non-empty summary");
483
1345
  const source = this.resume(sourceSessionId);
484
- const events = source.transcript.getEvents();
485
- // Determine fork point
486
- const forkTurn = forkAtTurn ?? source.state.turnCount;
487
- // Create new session
488
- const newBundle = this.create(source.state.cwd, source.state.model, source.state.provider);
489
- newBundle.state.parentSessionId = sourceSessionId;
490
- // Copy events up to the fork point
491
- for (const event of events) {
492
- if (event.type === "turn_boundary" &&
493
- (event.data.turnNumber ?? -1) > forkTurn) {
494
- break;
1346
+ const targetSessionId = options.targetSessionId ?? nanoid(16);
1347
+ const createdAt = Date.now();
1348
+ const lineage = {
1349
+ sessionId: sourceSessionId,
1350
+ mode: "summary",
1351
+ fromEventId: options.fromEventId,
1352
+ throughEventId: options.toEventId,
1353
+ sourceEventCount: options.sourceEventCount,
1354
+ createdAt,
1355
+ };
1356
+ const state = buildForkState(source.state, targetSessionId, lineage, createdAt);
1357
+ const [meta] = buildForkTranscript([], state);
1358
+ const summaryEvent = {
1359
+ id: nanoid(12),
1360
+ type: "context_transfer",
1361
+ timestamp: createdAt,
1362
+ turnNumber: 0,
1363
+ data: {
1364
+ summary: options.summary,
1365
+ sourceRange: {
1366
+ sessionId: sourceSessionId,
1367
+ fromEventId: options.fromEventId,
1368
+ toEventId: options.toEventId,
1369
+ },
1370
+ sourceEventCount: options.sourceEventCount,
1371
+ estimatedTokens: options.estimatedTokens,
1372
+ summaryVersion: 1,
1373
+ summaryHash: createHash("sha256").update(options.summary).digest("hex"),
1374
+ },
1375
+ };
1376
+ const bundle = this.publishSessionAtomically(targetSessionId, state, [meta, summaryEvent]);
1377
+ return { bundle, lineage, copiedEventCount: 0 };
1378
+ }
1379
+ readForkSnapshot(sourceSessionId, throughEventId, snapshotMode) {
1380
+ const sessionDir = join(this.sessionsDir, sourceSessionId);
1381
+ const stateFile = join(sessionDir, "state.json");
1382
+ const transcriptFile = join(sessionDir, "transcript.jsonl");
1383
+ if (!existsSync(stateFile))
1384
+ throw new SessionError(`Session not found: ${sourceSessionId}`);
1385
+ let sourceState;
1386
+ try {
1387
+ sourceState = JSON.parse(readFileSync(stateFile, "utf-8"));
1388
+ }
1389
+ catch (err) {
1390
+ throw new SessionError(`Session state is corrupt for ${sourceSessionId}: ${err instanceof Error ? err.message : String(err)}`);
1391
+ }
1392
+ const parsed = Transcript.readEvents(transcriptFile);
1393
+ if (parsed.malformedLineCount > 0) {
1394
+ throw new SessionError(`Session transcript is malformed for ${sourceSessionId}: ${parsed.malformedLineCount} invalid line(s)`);
1395
+ }
1396
+ const sourceEvents = structuredClone(parsed.events);
1397
+ let frozen = sourceEvents;
1398
+ const effectiveCursor = snapshotMode === "completed" ? sourceState.completedThroughEventId : throughEventId;
1399
+ if (snapshotMode === "completed" && effectiveCursor === undefined) {
1400
+ // Legacy sessions predate completedThroughEventId. Their tail is a safe
1401
+ // completed snapshot only when the persisted lifecycle status says the
1402
+ // last run completed naturally. A modern schema marker without a cursor
1403
+ // means persistence was degraded, so even status=completed must not use
1404
+ // the tail. Active/error/abort tails may be partial as well.
1405
+ frozen =
1406
+ sourceState.completedSnapshotVersion === undefined && sourceState.status === "completed"
1407
+ ? sourceEvents
1408
+ : [];
1409
+ }
1410
+ else if (effectiveCursor !== undefined) {
1411
+ const matches = sourceEvents
1412
+ .map((event, index) => (event.id === effectiveCursor ? index : -1))
1413
+ .filter((index) => index >= 0);
1414
+ if (matches.length !== 1) {
1415
+ throw new SessionError(snapshotMode === "completed"
1416
+ ? `Completed fork cursor must identify exactly one source event`
1417
+ : `Fork cursor must identify exactly one source event`);
1418
+ }
1419
+ const cursor = sourceEvents[matches[0]];
1420
+ if (cursor.type === "session_meta") {
1421
+ throw new SessionError(`Fork cursor cannot point at session metadata`);
1422
+ }
1423
+ frozen = sourceEvents.slice(0, matches[0] + 1);
1424
+ }
1425
+ const copiedEvents = [];
1426
+ for (const event of frozen) {
1427
+ if (FORK_SKIP_EVENT_TYPES.has(event.type))
1428
+ continue;
1429
+ if (!FORK_COPY_EVENT_TYPES.has(event.type)) {
1430
+ throw new SessionError(`Unsupported transcript event in fork: ${String(event.type)}`);
1431
+ }
1432
+ copiedEvents.push(structuredClone(event));
1433
+ }
1434
+ validateForkToolPairs(copiedEvents);
1435
+ return { sourceState: structuredClone(sourceState), copiedEvents };
1436
+ }
1437
+ publishSessionAtomically(targetSessionId, state, events) {
1438
+ const targetDir = join(this.sessionsDir, targetSessionId);
1439
+ if (existsSync(targetDir))
1440
+ throw new SessionError(`Session already exists: ${targetSessionId}`);
1441
+ const stagingDir = join(this.sessionsDir, `.pending-fork-${targetSessionId}-${nanoid(8)}`);
1442
+ let published = false;
1443
+ try {
1444
+ mkdirSync(stagingDir);
1445
+ writeFileSync(join(stagingDir, "state.json"), JSON.stringify(state, null, 2), {
1446
+ encoding: "utf-8",
1447
+ mode: 0o600,
1448
+ });
1449
+ const jsonl = events.map((event) => JSON.stringify(event)).join("\n") + "\n";
1450
+ writeFileSync(join(stagingDir, "transcript.jsonl"), jsonl, {
1451
+ encoding: "utf-8",
1452
+ mode: 0o600,
1453
+ });
1454
+ if (existsSync(targetDir)) {
1455
+ throw new SessionError(`Session already exists: ${targetSessionId}`);
495
1456
  }
496
- newBundle.transcript.append(event.type, event.data);
1457
+ renameSync(stagingDir, targetDir);
1458
+ published = true;
1459
+ return this.resume(targetSessionId);
1460
+ }
1461
+ finally {
1462
+ if (!published)
1463
+ rmSync(stagingDir, { recursive: true, force: true });
497
1464
  }
498
- this.saveState(newBundle.state);
499
- return newBundle;
500
1465
  }
501
- list(limit = 20) {
1466
+ list(limit = 20, opts) {
502
1467
  if (!existsSync(this.sessionsDir))
503
1468
  return [];
1469
+ // Extension-owned session kinds stay out of generic lists. Defaults to
1470
+ // hiding "pet" so default-arg callers keep today's behavior.
1471
+ // TODO(pet-out-of-core): drop the default once every caller passes the
1472
+ // host's hidden-kind union explicitly.
1473
+ const excludeKinds = opts?.excludeKinds ?? ["pet"];
504
1474
  const dirs = readdirSync(this.sessionsDir, { withFileTypes: true })
505
- .filter((d) => d.isDirectory())
1475
+ .filter((d) => d.isDirectory() && !d.name.startsWith(".pending-") && !d.name.startsWith("qchat-"))
506
1476
  .map((d) => d.name);
507
1477
  const candidates = [];
508
1478
  for (const dir of dirs) {
@@ -529,11 +1499,17 @@ export class SessionManager {
529
1499
  candidates.push({ dir, lastActiveAt, transcriptFile, stateFile, transcriptExists });
530
1500
  }
531
1501
  candidates.sort((a, b) => b.lastActiveAt - a.lastActiveAt);
532
- const top = candidates.slice(0, limit);
533
1502
  const sessions = [];
534
- for (const c of top) {
1503
+ for (const c of candidates) {
1504
+ if (sessions.length >= limit)
1505
+ break;
535
1506
  try {
536
1507
  const state = JSON.parse(readFileSync(c.stateFile, "utf-8"));
1508
+ if (isEphemeralSessionState(state))
1509
+ continue;
1510
+ state.kind = normalizedSessionKind(state.kind);
1511
+ if (excludeKinds.includes(state.kind))
1512
+ continue;
537
1513
  const preview = c.transcriptExists ? readLastUserMessage(c.transcriptFile) : undefined;
538
1514
  sessions.push({ ...state, preview, lastActiveAt: c.lastActiveAt });
539
1515
  }
@@ -544,14 +1520,154 @@ export class SessionManager {
544
1520
  return sessions;
545
1521
  }
546
1522
  }
547
- function newestGoalTerminal(incoming, persisted) {
548
- if (!incoming)
549
- return persisted;
550
- if (!persisted)
551
- return incoming;
552
- const incomingAt = incoming.terminatedAtMs ?? Number.NEGATIVE_INFINITY;
553
- const persistedAt = persisted.terminatedAtMs ?? Number.NEGATIVE_INFINITY;
554
- return incomingAt > persistedAt ? incoming : persisted;
1523
+ export function buildForkState(source, targetSessionId, lineage, startedAt = Date.now()) {
1524
+ const workspace = isSessionWorkspace(source.workspace)
1525
+ ? structuredClone(source.workspace)
1526
+ : { root: source.cwd, kind: "main" };
1527
+ return {
1528
+ sessionId: targetSessionId,
1529
+ kind: "work",
1530
+ stateRevision: 0,
1531
+ cwd: source.cwd,
1532
+ workspace,
1533
+ startedAt,
1534
+ model: source.model,
1535
+ provider: source.provider,
1536
+ tokenUsage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
1537
+ cumulativePromptTokens: 0,
1538
+ cumulativeCacheReadTokens: 0,
1539
+ cumulativeCacheCreationTokens: 0,
1540
+ turnCount: 0,
1541
+ turnSeq: 0,
1542
+ invokedSkills: [],
1543
+ parentSessionId: null,
1544
+ forkedFrom: structuredClone(lineage),
1545
+ ...(source.origin ? { origin: source.origin } : {}),
1546
+ status: "active",
1547
+ };
1548
+ }
1549
+ export function buildForkTranscript(sourceEvents, state) {
1550
+ const meta = {
1551
+ id: nanoid(12),
1552
+ type: "session_meta",
1553
+ timestamp: state.startedAt,
1554
+ turnNumber: 0,
1555
+ data: {
1556
+ sessionId: state.sessionId,
1557
+ cwd: state.cwd,
1558
+ workspace: structuredClone(state.workspace),
1559
+ model: state.model,
1560
+ provider: state.provider,
1561
+ startedAt: state.startedAt,
1562
+ forkedFrom: structuredClone(state.forkedFrom),
1563
+ },
1564
+ };
1565
+ return [
1566
+ meta,
1567
+ ...sourceEvents.map((source) => ({
1568
+ ...structuredClone(source),
1569
+ id: nanoid(12),
1570
+ })),
1571
+ ];
1572
+ }
1573
+ function validateForkToolPairs(events) {
1574
+ const projectedUses = [];
1575
+ const projectedResults = [];
1576
+ const seenProjectedUses = new Set();
1577
+ const seenProjectedResults = new Set();
1578
+ const projectedPending = [];
1579
+ for (const message of Transcript.eventsToMessages(events)) {
1580
+ const blocks = Array.isArray(message.content) ? message.content : [];
1581
+ const uses = blocks.filter((block) => block.type === "tool_use");
1582
+ const results = blocks.filter((block) => block.type === "tool_result");
1583
+ if (projectedPending.length > 0) {
1584
+ if (message.role !== "user" || results.length !== blocks.length || results.length === 0) {
1585
+ throw new SessionError(`Fork provider history inserts a message before pending tool results`);
1586
+ }
1587
+ }
1588
+ else if (results.length > 0) {
1589
+ throw new SessionError(`Fork provider history contains an orphaned tool result`);
1590
+ }
1591
+ if (uses.length > 0) {
1592
+ if (message.role !== "assistant" || results.length > 0) {
1593
+ throw new SessionError(`Fork provider history contains tool use blocks in an invalid role`);
1594
+ }
1595
+ for (const block of uses) {
1596
+ const id = block.id;
1597
+ if (typeof id !== "string" || !id) {
1598
+ throw new SessionError(`Fork provider history contains a tool use without id`);
1599
+ }
1600
+ if (seenProjectedUses.has(id)) {
1601
+ throw new SessionError(`Fork provider history contains duplicate tool use ${id}`);
1602
+ }
1603
+ seenProjectedUses.add(id);
1604
+ projectedUses.push(id);
1605
+ projectedPending.push(id);
1606
+ }
1607
+ }
1608
+ for (const block of results) {
1609
+ const id = block.tool_use_id;
1610
+ if (typeof id !== "string" || !id) {
1611
+ throw new SessionError(`Fork provider history contains a tool result without id`);
1612
+ }
1613
+ if (seenProjectedResults.has(id)) {
1614
+ throw new SessionError(`Fork provider history contains duplicate tool result ${id}`);
1615
+ }
1616
+ const expected = projectedPending.shift();
1617
+ if (!expected) {
1618
+ throw new SessionError(`Fork provider history contains an orphaned tool result (${id})`);
1619
+ }
1620
+ if (expected !== id) {
1621
+ throw new SessionError(`Fork provider history tool result order mismatch: expected ${expected}, received ${id}`);
1622
+ }
1623
+ seenProjectedResults.add(id);
1624
+ projectedResults.push(id);
1625
+ }
1626
+ }
1627
+ if (projectedPending.length > 0) {
1628
+ throw new SessionError(`Fork cursor splits an unfinished tool round in provider history (${projectedPending[0]})`);
1629
+ }
1630
+ const metadataUses = [];
1631
+ const metadataResults = [];
1632
+ const seenMetadataUses = new Set();
1633
+ const seenMetadataResults = new Set();
1634
+ const metadataPending = [];
1635
+ for (const event of events) {
1636
+ if (event.type !== "tool_use" && event.type !== "tool_result")
1637
+ continue;
1638
+ const id = event.data.toolCallId;
1639
+ if (typeof id !== "string" || !id) {
1640
+ throw new SessionError(`Fork snapshot contains a tool event without toolCallId`);
1641
+ }
1642
+ if (event.type === "tool_use") {
1643
+ if (seenMetadataUses.has(id)) {
1644
+ throw new SessionError(`Fork snapshot contains duplicate tool use metadata ${id}`);
1645
+ }
1646
+ seenMetadataUses.add(id);
1647
+ metadataUses.push(id);
1648
+ metadataPending.push(id);
1649
+ continue;
1650
+ }
1651
+ if (seenMetadataResults.has(id)) {
1652
+ throw new SessionError(`Fork snapshot contains duplicate tool result metadata ${id}`);
1653
+ }
1654
+ const expected = metadataPending.shift();
1655
+ if (!expected) {
1656
+ throw new SessionError(`Fork snapshot contains tool result metadata before use (${id})`);
1657
+ }
1658
+ if (expected !== id) {
1659
+ throw new SessionError(`Fork snapshot tool metadata order mismatch: expected ${expected}, received ${id}`);
1660
+ }
1661
+ seenMetadataResults.add(id);
1662
+ metadataResults.push(id);
1663
+ }
1664
+ if (metadataPending.length > 0) {
1665
+ throw new SessionError(`Fork cursor splits unfinished tool metadata (${metadataPending[0]})`);
1666
+ }
1667
+ const sameIds = (left, right) => left.length === right.length && left.every((id, index) => id === right[index]);
1668
+ if (!sameIds(projectedUses, metadataUses) || !sameIds(projectedResults, metadataResults)) {
1669
+ throw new SessionError(`Fork tool metadata does not match provider history`);
1670
+ }
555
1671
  }
556
1672
  /**
557
1673
  * Scan a transcript.jsonl for the LAST user message and return a short