@cjhyy/code-shell-core 0.7.1 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (595) 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 +7 -3
  23. package/dist/context/compaction.d.ts +39 -3
  24. package/dist/context/compaction.js +151 -39
  25. package/dist/context/manager.d.ts +17 -1
  26. package/dist/context/manager.js +36 -1
  27. package/dist/credentials/access.d.ts +3 -1
  28. package/dist/credentials/access.js +24 -3
  29. package/dist/credentials/index.d.ts +1 -1
  30. package/dist/credentials/index.js +1 -1
  31. package/dist/credentials/oauth.d.ts +4 -1
  32. package/dist/credentials/oauth.js +14 -0
  33. package/dist/credentials/store.d.ts +15 -1
  34. package/dist/credentials/store.js +17 -4
  35. package/dist/credentials/types.d.ts +24 -0
  36. package/dist/credentials/use-credential-tool.js +15 -1
  37. package/dist/engine/auxiliary-pipeline.d.ts +40 -0
  38. package/dist/engine/auxiliary-pipeline.js +181 -0
  39. package/dist/{settings → engine}/disk-defaults.d.ts +2 -2
  40. package/dist/{settings → engine}/disk-defaults.js +5 -2
  41. package/dist/engine/dynamic-tool-defs.d.ts +4 -0
  42. package/dist/engine/dynamic-tool-defs.js +4 -0
  43. package/dist/engine/engine.d.ts +254 -195
  44. package/dist/engine/engine.js +1812 -2041
  45. package/dist/engine/file-history-hook.d.ts +11 -0
  46. package/dist/engine/file-history-hook.js +31 -0
  47. package/dist/engine/goal-judge-context.d.ts +9 -0
  48. package/dist/engine/goal-judge-context.js +234 -0
  49. package/dist/engine/injected-context-cache.d.ts +6 -0
  50. package/dist/engine/injected-context-cache.js +13 -0
  51. package/dist/engine/input-attachments.d.ts +1 -1
  52. package/dist/engine/model-facade.d.ts +3 -3
  53. package/dist/engine/model-facade.js +15 -14
  54. package/dist/engine/permission-controller.d.ts +55 -0
  55. package/dist/engine/permission-controller.js +148 -0
  56. package/dist/engine/prompt-cache-diagnostics.d.ts +58 -0
  57. package/dist/engine/prompt-cache-diagnostics.js +178 -0
  58. package/dist/engine/run-accounting.d.ts +41 -0
  59. package/dist/engine/run-accounting.js +126 -0
  60. package/dist/engine/run-context.d.ts +44 -0
  61. package/dist/engine/run-context.js +88 -0
  62. package/dist/engine/run-environment.d.ts +30 -0
  63. package/dist/engine/run-environment.js +77 -0
  64. package/dist/engine/run-finalize.d.ts +58 -0
  65. package/dist/engine/run-finalize.js +245 -0
  66. package/dist/engine/run-goal.d.ts +58 -0
  67. package/dist/engine/run-goal.js +188 -0
  68. package/dist/engine/run-image-input.d.ts +1 -1
  69. package/dist/engine/run-image-input.js +5 -3
  70. package/dist/engine/run-session-open.d.ts +41 -0
  71. package/dist/engine/run-session-open.js +186 -0
  72. package/dist/engine/run-setup.d.ts +42 -0
  73. package/dist/engine/run-setup.js +58 -0
  74. package/dist/engine/run-tooling.d.ts +125 -0
  75. package/dist/engine/run-tooling.js +283 -0
  76. package/dist/engine/run-types.d.ts +139 -0
  77. package/dist/engine/run-types.js +12 -0
  78. package/dist/engine/run-workspace.d.ts +50 -0
  79. package/dist/engine/run-workspace.js +114 -0
  80. package/dist/engine/steer-queue.d.ts +1 -1
  81. package/dist/engine/subagent-spawner.d.ts +32 -0
  82. package/dist/engine/subagent-spawner.js +287 -0
  83. package/dist/engine/tool-summary.d.ts +2 -2
  84. package/dist/engine/tool-summary.js +2 -2
  85. package/dist/engine/turn-loop.d.ts +49 -10
  86. package/dist/engine/turn-loop.js +370 -104
  87. package/dist/engine/types.d.ts +30 -3
  88. package/dist/engine/types.js +1 -1
  89. package/dist/{engine/goal.d.ts → goal/lifecycle.d.ts} +76 -4
  90. package/dist/goal/lifecycle.js +482 -0
  91. package/dist/hooks/events.d.ts +24 -10
  92. package/dist/hooks/goal-stop-hook.d.ts +17 -28
  93. package/dist/hooks/goal-stop-hook.js +110 -465
  94. package/dist/hooks/registry.js +1 -1
  95. package/dist/index.d.ts +48 -108
  96. package/dist/index.extension.d.ts +51 -0
  97. package/dist/index.extension.js +39 -0
  98. package/dist/index.internal.d.ts +86 -0
  99. package/dist/index.internal.js +92 -0
  100. package/dist/index.js +34 -123
  101. package/dist/links/cli.d.ts +48 -0
  102. package/dist/links/cli.js +617 -0
  103. package/dist/links/http.d.ts +40 -0
  104. package/dist/links/http.js +166 -0
  105. package/dist/links/index.d.ts +4 -0
  106. package/dist/links/index.js +3 -0
  107. package/dist/links/link-action-tool.d.ts +5 -0
  108. package/dist/links/link-action-tool.js +241 -0
  109. package/dist/links/providers.d.ts +8 -0
  110. package/dist/links/providers.js +665 -0
  111. package/dist/links/types.d.ts +44 -0
  112. package/dist/llm/client-base.d.ts +7 -0
  113. package/dist/llm/client-base.js +60 -0
  114. package/dist/llm/model-pool.d.ts +1 -1
  115. package/dist/llm/model-pool.js +1 -1
  116. package/dist/llm/providers/anthropic.d.ts +1 -0
  117. package/dist/llm/providers/anthropic.js +9 -0
  118. package/dist/llm/providers/openai.d.ts +1 -0
  119. package/dist/llm/providers/openai.js +17 -0
  120. package/dist/model-catalog/index.js +15 -2
  121. package/dist/model-catalog/resolve.d.ts +13 -1
  122. package/dist/model-catalog/resolve.js +37 -2
  123. package/dist/model-catalog/types.d.ts +74 -48
  124. package/dist/model-catalog/types.js +16 -5
  125. package/dist/model-catalog/upsert.d.ts +3 -1
  126. package/dist/model-catalog/upsert.js +9 -0
  127. package/dist/onboarding.d.ts +0 -1
  128. package/dist/onboarding.js +1 -22
  129. package/dist/panel-apps/bindings.d.ts +24 -0
  130. package/dist/panel-apps/bindings.js +69 -0
  131. package/dist/panel-apps/github-archive.d.ts +21 -0
  132. package/dist/panel-apps/github-archive.js +98 -0
  133. package/dist/panel-apps/index.d.ts +4 -0
  134. package/dist/panel-apps/index.js +4 -0
  135. package/dist/panel-apps/installer.d.ts +68 -0
  136. package/dist/panel-apps/installer.js +622 -0
  137. package/dist/panel-apps/manifest.d.ts +364 -0
  138. package/dist/panel-apps/manifest.js +189 -0
  139. package/dist/panel-apps/paths.d.ts +13 -0
  140. package/dist/panel-apps/paths.js +38 -0
  141. package/dist/panel-apps/registry.d.ts +51 -0
  142. package/dist/panel-apps/registry.js +99 -0
  143. package/dist/panel-apps/runtime.d.ts +80 -0
  144. package/dist/panel-apps/runtime.js +154 -0
  145. package/dist/plugins/gitOps.js +8 -1
  146. package/dist/plugins/installedPlugins.d.ts +2 -1
  147. package/dist/plugins/installedPlugins.js +146 -8
  148. package/dist/plugins/installer/codex/convertAgents.d.ts +4 -1
  149. package/dist/plugins/installer/codex/convertAgents.js +62 -8
  150. package/dist/plugins/installer/codex/convertCommands.d.ts +5 -5
  151. package/dist/plugins/installer/codex/convertCommands.js +35 -12
  152. package/dist/plugins/installer/codex/convertHooks.d.ts +10 -0
  153. package/dist/plugins/installer/codex/convertHooks.js +82 -0
  154. package/dist/plugins/installer/codex/convertSkills.js +41 -6
  155. package/dist/plugins/installer/install.d.ts +7 -1
  156. package/dist/plugins/installer/install.js +21 -87
  157. package/dist/plugins/installer/installFromArchive.d.ts +26 -4
  158. package/dist/plugins/installer/installFromArchive.js +44 -27
  159. package/dist/plugins/installer/installFromNpm.d.ts +31 -0
  160. package/dist/plugins/installer/installFromNpm.js +273 -0
  161. package/dist/plugins/installer/loadPluginAgents.js +17 -4
  162. package/dist/plugins/installer/loadPluginMcp.d.ts +14 -0
  163. package/dist/plugins/installer/loadPluginMcp.js +245 -35
  164. package/dist/plugins/installer/normalizeManifest.d.ts +13 -0
  165. package/dist/plugins/installer/normalizeManifest.js +306 -0
  166. package/dist/plugins/installer/npmTar.d.ts +13 -0
  167. package/dist/plugins/installer/npmTar.js +321 -0
  168. package/dist/plugins/installer/parseSource.d.ts +11 -0
  169. package/dist/plugins/installer/parseSource.js +48 -0
  170. package/dist/plugins/installer/preview.d.ts +101 -0
  171. package/dist/plugins/installer/preview.js +336 -0
  172. package/dist/plugins/installer/projectPluginSource.d.ts +14 -0
  173. package/dist/plugins/installer/projectPluginSource.js +140 -0
  174. package/dist/plugins/installer/types.d.ts +775 -0
  175. package/dist/plugins/installer/types.js +100 -0
  176. package/dist/plugins/installer/unzip.d.ts +11 -0
  177. package/dist/plugins/installer/unzip.js +123 -9
  178. package/dist/plugins/installer/update.d.ts +2 -2
  179. package/dist/plugins/installer/update.js +43 -4
  180. package/dist/plugins/loadPluginHooks.d.ts +19 -2
  181. package/dist/plugins/loadPluginHooks.js +97 -86
  182. package/dist/plugins/pluginAutomationTemplates.d.ts +18 -0
  183. package/dist/plugins/pluginAutomationTemplates.js +50 -0
  184. package/dist/plugins/pluginCatalog.d.ts +45 -0
  185. package/dist/plugins/pluginCatalog.js +87 -0
  186. package/dist/plugins/pluginCommandHook.d.ts +6 -0
  187. package/dist/plugins/pluginCommandHook.js +25 -8
  188. package/dist/plugins/pluginCommandsLoader.d.ts +19 -0
  189. package/dist/plugins/pluginCommandsLoader.js +181 -7
  190. package/dist/plugins/pluginContent.d.ts +10 -1
  191. package/dist/plugins/pluginContent.js +87 -30
  192. package/dist/plugins/pluginHookApproval.d.ts +30 -0
  193. package/dist/plugins/pluginHookApproval.js +160 -0
  194. package/dist/plugins/pluginHookIntegrity.d.ts +69 -0
  195. package/dist/plugins/pluginHookIntegrity.js +361 -0
  196. package/dist/plugins/pluginInstaller.js +71 -12
  197. package/dist/plugins/pluginMcpApproval.d.ts +19 -0
  198. package/dist/plugins/pluginMcpApproval.js +110 -0
  199. package/dist/plugins/pluginMcpIntegrity.d.ts +21 -0
  200. package/dist/plugins/pluginMcpIntegrity.js +74 -0
  201. package/dist/plugins/types.d.ts +30 -0
  202. package/dist/plugins/varRewrite.d.ts +9 -5
  203. package/dist/plugins/varRewrite.js +26 -17
  204. package/dist/preset/index.d.ts +11 -8
  205. package/dist/preset/index.js +62 -203
  206. package/dist/product/define.js +9 -3
  207. package/dist/product/types.d.ts +0 -2
  208. package/dist/profile/activation.d.ts +36 -0
  209. package/dist/profile/activation.js +57 -0
  210. package/dist/profile/catalog-store.d.ts +77 -0
  211. package/dist/profile/catalog-store.js +346 -0
  212. package/dist/profile/catalog.d.ts +54 -0
  213. package/dist/profile/catalog.js +152 -0
  214. package/dist/profile/index.d.ts +7 -0
  215. package/dist/profile/index.js +7 -0
  216. package/dist/profile/requirements.d.ts +76 -0
  217. package/dist/profile/requirements.js +117 -0
  218. package/dist/profile/resolve.d.ts +11 -0
  219. package/dist/profile/resolve.js +41 -0
  220. package/dist/profile/store.d.ts +17 -0
  221. package/dist/profile/store.js +167 -0
  222. package/dist/profile/types.d.ts +321 -0
  223. package/dist/profile/types.js +104 -0
  224. package/dist/prompt/composer.d.ts +46 -7
  225. package/dist/prompt/composer.js +78 -48
  226. package/dist/prompt/instruction-scanner.d.ts +6 -5
  227. package/dist/prompt/instruction-scanner.js +8 -23
  228. package/dist/prompt/section-loader.d.ts +2 -2
  229. package/dist/prompt/section-loader.js +7 -4
  230. package/dist/prompt/sections/browser.md +10 -9
  231. package/dist/prompt/sections/harness-base.md +9 -0
  232. package/dist/protocol/chat-session-manager.d.ts +64 -1
  233. package/dist/protocol/chat-session-manager.js +104 -0
  234. package/dist/protocol/chat-session.d.ts +61 -10
  235. package/dist/protocol/chat-session.js +135 -25
  236. package/dist/protocol/client.d.ts +33 -3
  237. package/dist/protocol/client.js +96 -5
  238. package/dist/protocol/index.d.ts +2 -2
  239. package/dist/protocol/index.js +2 -2
  240. package/dist/protocol/mobile-remote-types.d.ts +406 -0
  241. package/dist/protocol/mobile-remote-types.js +1 -0
  242. package/dist/protocol/server.d.ts +127 -3
  243. package/dist/protocol/server.js +1309 -182
  244. package/dist/protocol/types.d.ts +152 -34
  245. package/dist/protocol/types.js +16 -0
  246. package/dist/run/ArtifactTracker.d.ts +6 -1
  247. package/dist/run/ArtifactTracker.js +29 -21
  248. package/dist/run/EngineRunner.d.ts +2 -0
  249. package/dist/run/EngineRunner.js +1 -0
  250. package/dist/run/RunManager.d.ts +26 -0
  251. package/dist/run/RunManager.js +82 -11
  252. package/dist/run/factory.d.ts +3 -0
  253. package/dist/run/factory.js +2 -0
  254. package/dist/runtime/safe-spawn.d.ts +6 -0
  255. package/dist/runtime/safe-spawn.js +8 -13
  256. package/dist/services/auto-dream.d.ts +13 -1
  257. package/dist/services/auto-dream.js +56 -8
  258. package/dist/services/dream-consolidation.js +10 -1
  259. package/dist/services/memory-orchestrator.js +6 -2
  260. package/dist/services/oauth.js +4 -1
  261. package/dist/services/session-memory.d.ts +4 -4
  262. package/dist/services/session-memory.js +13 -10
  263. package/dist/session/memory.d.ts +26 -7
  264. package/dist/session/memory.js +79 -21
  265. package/dist/session/session-manager.d.ts +182 -28
  266. package/dist/session/session-manager.js +910 -130
  267. package/dist/session/session-message.d.ts +22 -0
  268. package/dist/session/session-message.js +1 -0
  269. package/dist/session/transcript.d.ts +46 -5
  270. package/dist/session/transcript.js +159 -3
  271. package/dist/settings/feature-flags.d.ts +40 -0
  272. package/dist/settings/feature-flags.js +40 -0
  273. package/dist/settings/manager.d.ts +39 -1
  274. package/dist/settings/manager.js +102 -41
  275. package/dist/settings/migrate-config.js +4 -2
  276. package/dist/settings/schema.d.ts +511 -177
  277. package/dist/settings/schema.js +98 -25
  278. package/dist/skills/scanner.d.ts +6 -2
  279. package/dist/skills/scanner.js +160 -9
  280. package/dist/sources/adapter.d.ts +14 -0
  281. package/dist/sources/adapter.js +7 -0
  282. package/dist/sources/adapters/local-files.d.ts +10 -0
  283. package/dist/sources/adapters/local-files.js +127 -0
  284. package/dist/sources/adapters/mcp-resource.d.ts +17 -0
  285. package/dist/sources/adapters/mcp-resource.js +41 -0
  286. package/dist/sources/adapters/mock.d.ts +3 -0
  287. package/dist/sources/adapters/mock.js +29 -0
  288. package/dist/sources/binding.d.ts +6 -0
  289. package/dist/sources/binding.js +24 -0
  290. package/dist/sources/catalog.d.ts +6 -0
  291. package/dist/sources/catalog.js +64 -0
  292. package/dist/sources/context-summary.d.ts +8 -0
  293. package/dist/sources/context-summary.js +13 -0
  294. package/dist/sources/credential-status.d.ts +2 -0
  295. package/dist/sources/credential-status.js +13 -0
  296. package/dist/sources/index.d.ts +10 -0
  297. package/dist/sources/index.js +10 -0
  298. package/dist/sources/resolve.d.ts +28 -0
  299. package/dist/sources/resolve.js +47 -0
  300. package/dist/sources/truncate-utf8.d.ts +7 -0
  301. package/dist/sources/truncate-utf8.js +18 -0
  302. package/dist/sources/types.d.ts +71 -0
  303. package/dist/sources/types.js +26 -0
  304. package/dist/testing/fetch-stub.d.ts +32 -0
  305. package/dist/testing/fetch-stub.js +24 -0
  306. package/dist/themes/image.d.ts +18 -0
  307. package/dist/themes/image.js +117 -0
  308. package/dist/themes/index.d.ts +4 -0
  309. package/dist/themes/index.js +4 -0
  310. package/dist/themes/installer.d.ts +50 -0
  311. package/dist/themes/installer.js +290 -0
  312. package/dist/themes/manifest.d.ts +102 -0
  313. package/dist/themes/manifest.js +86 -0
  314. package/dist/themes/paths.d.ts +14 -0
  315. package/dist/themes/paths.js +33 -0
  316. package/dist/tool-system/browser-bridge.d.ts +53 -11
  317. package/dist/tool-system/browser-bridge.js +8 -9
  318. package/dist/tool-system/builtin/agent-heartbeat.d.ts +2 -2
  319. package/dist/tool-system/builtin/agent-heartbeat.js +20 -6
  320. package/dist/tool-system/builtin/agent-notifications.d.ts +127 -72
  321. package/dist/tool-system/builtin/agent-notifications.js +291 -101
  322. package/dist/tool-system/builtin/agent-output-file.js +1 -3
  323. package/dist/tool-system/builtin/agent-progress.d.ts +6 -0
  324. package/dist/tool-system/builtin/agent-progress.js +83 -0
  325. package/dist/tool-system/builtin/agent-registry.d.ts +38 -2
  326. package/dist/tool-system/builtin/agent-registry.js +178 -9
  327. package/dist/tool-system/builtin/agent-transcript-translator.js +1 -1
  328. package/dist/tool-system/builtin/agent.d.ts +2 -0
  329. package/dist/tool-system/builtin/agent.js +274 -33
  330. package/dist/tool-system/builtin/background-jobs.d.ts +55 -2
  331. package/dist/tool-system/builtin/background-jobs.js +96 -4
  332. package/dist/tool-system/builtin/background-work.d.ts +6 -0
  333. package/dist/tool-system/builtin/background-work.js +8 -1
  334. package/dist/tool-system/builtin/bash.js +5 -2
  335. package/dist/tool-system/builtin/browser-tools.d.ts +6 -3
  336. package/dist/tool-system/builtin/browser-tools.js +89 -23
  337. package/dist/tool-system/builtin/edit-model-catalog.d.ts +6 -0
  338. package/dist/tool-system/builtin/edit-model-catalog.js +172 -12
  339. package/dist/tool-system/builtin/edit.js +2 -6
  340. package/dist/tool-system/builtin/file-cache.d.ts +2 -0
  341. package/dist/tool-system/builtin/file-cache.js +4 -0
  342. package/dist/tool-system/builtin/generate-image.js +17 -10
  343. package/dist/tool-system/builtin/generate-video.js +26 -7
  344. package/dist/tool-system/builtin/grep.js +2 -2
  345. package/dist/tool-system/builtin/image-uploader.js +7 -1
  346. package/dist/tool-system/builtin/index.d.ts +32 -7
  347. package/dist/tool-system/builtin/index.js +237 -167
  348. package/dist/tool-system/builtin/memory.d.ts +6 -0
  349. package/dist/tool-system/builtin/memory.js +36 -15
  350. package/dist/tool-system/builtin/panel.d.ts +9 -0
  351. package/dist/tool-system/builtin/panel.js +314 -0
  352. package/dist/tool-system/builtin/read.js +5 -1
  353. package/dist/tool-system/builtin/send-message-to-session.d.ts +6 -0
  354. package/dist/tool-system/builtin/send-message-to-session.js +67 -0
  355. package/dist/tool-system/builtin/sources.d.ts +8 -0
  356. package/dist/tool-system/builtin/sources.js +137 -0
  357. package/dist/tool-system/builtin/tool-search.js +7 -2
  358. package/dist/tool-system/builtin/update-automation-memory.js +2 -0
  359. package/dist/tool-system/builtin/web-fetch.js +17 -13
  360. package/dist/tool-system/builtin/web-search.js +1 -3
  361. package/dist/tool-system/capability-module.d.ts +93 -0
  362. package/dist/tool-system/capability-module.js +53 -0
  363. package/dist/tool-system/context.d.ts +77 -19
  364. package/dist/tool-system/executor.js +75 -50
  365. package/dist/tool-system/external-tool-exposure.d.ts +60 -0
  366. package/dist/tool-system/external-tool-exposure.js +304 -0
  367. package/dist/tool-system/mcp-manager.d.ts +11 -1
  368. package/dist/tool-system/mcp-manager.js +19 -4
  369. package/dist/tool-system/mcp-tool-policy.d.ts +9 -0
  370. package/dist/tool-system/mcp-tool-policy.js +34 -0
  371. package/dist/tool-system/panel-bridge.d.ts +53 -0
  372. package/dist/tool-system/panel-bridge.js +1 -0
  373. package/dist/tool-system/path-policy.js +99 -2
  374. package/dist/tool-system/permission.d.ts +26 -0
  375. package/dist/tool-system/permission.js +46 -0
  376. package/dist/tool-system/registry.d.ts +12 -1
  377. package/dist/tool-system/registry.js +31 -7
  378. package/dist/tool-system/sandbox/index.js +3 -8
  379. package/dist/tool-system/sandbox/seatbelt.js +2 -6
  380. package/dist/tool-system/session-tool-host.d.ts +139 -0
  381. package/dist/tool-system/session-tool-host.js +167 -0
  382. package/dist/tool-system/testing/tool-registry-harness.d.ts +31 -0
  383. package/dist/tool-system/testing/tool-registry-harness.js +51 -0
  384. package/dist/tool-system/validation.d.ts +14 -7
  385. package/dist/tool-system/validation.js +701 -7
  386. package/dist/types.d.ts +186 -20
  387. package/dist/{cc-orchestrator → utils}/cwd-normalize.d.ts +1 -0
  388. package/dist/{cc-orchestrator → utils}/cwd-normalize.js +1 -0
  389. package/dist/utils/file-mutex.d.ts +65 -0
  390. package/dist/utils/file-mutex.js +145 -0
  391. package/dist/utils/json.d.ts +2 -4
  392. package/dist/utils/json.js +2 -4
  393. package/dist/utils/lockfile.d.ts +30 -1
  394. package/dist/utils/lockfile.js +2 -2
  395. package/dist/utils/secret-scrubber.d.ts +8 -0
  396. package/dist/utils/secret-scrubber.js +227 -0
  397. package/dist/utils/toolDisplay.js +1 -1
  398. package/package.json +20 -5
  399. package/THIRD_PARTY_NOTICES.md +0 -206
  400. package/dist/arena/arena.d.ts +0 -45
  401. package/dist/arena/arena.js +0 -362
  402. package/dist/arena/context/context-tools.d.ts +0 -16
  403. package/dist/arena/context/context-tools.js +0 -272
  404. package/dist/arena/context/within-root.d.ts +0 -7
  405. package/dist/arena/context/within-root.js +0 -15
  406. package/dist/arena/detect-mode.d.ts +0 -20
  407. package/dist/arena/detect-mode.js +0 -78
  408. package/dist/arena/digest-builder.d.ts +0 -25
  409. package/dist/arena/digest-builder.js +0 -120
  410. package/dist/arena/index.d.ts +0 -29
  411. package/dist/arena/index.js +0 -29
  412. package/dist/arena/iterate/convergence.d.ts +0 -25
  413. package/dist/arena/iterate/convergence.js +0 -103
  414. package/dist/arena/iterate/formats/index.d.ts +0 -22
  415. package/dist/arena/iterate/formats/index.js +0 -283
  416. package/dist/arena/iterate/index.d.ts +0 -11
  417. package/dist/arena/iterate/index.js +0 -9
  418. package/dist/arena/iterate/iterative-arena.d.ts +0 -23
  419. package/dist/arena/iterate/iterative-arena.js +0 -237
  420. package/dist/arena/iterate/parse.d.ts +0 -42
  421. package/dist/arena/iterate/parse.js +0 -123
  422. package/dist/arena/iterate/phases/argue.d.ts +0 -22
  423. package/dist/arena/iterate/phases/argue.js +0 -165
  424. package/dist/arena/iterate/phases/revise.d.ts +0 -16
  425. package/dist/arena/iterate/phases/revise.js +0 -62
  426. package/dist/arena/iterate/phases/tournament.d.ts +0 -34
  427. package/dist/arena/iterate/phases/tournament.js +0 -113
  428. package/dist/arena/iterate/tools/web-tools.d.ts +0 -13
  429. package/dist/arena/iterate/tools/web-tools.js +0 -54
  430. package/dist/arena/iterate/types.d.ts +0 -152
  431. package/dist/arena/iterate/types.js +0 -8
  432. package/dist/arena/ledger.d.ts +0 -47
  433. package/dist/arena/ledger.js +0 -159
  434. package/dist/arena/lenses/architecture.d.ts +0 -5
  435. package/dist/arena/lenses/architecture.js +0 -22
  436. package/dist/arena/lenses/engineering.d.ts +0 -5
  437. package/dist/arena/lenses/engineering.js +0 -22
  438. package/dist/arena/lenses/general.d.ts +0 -5
  439. package/dist/arena/lenses/general.js +0 -20
  440. package/dist/arena/lenses/index.d.ts +0 -16
  441. package/dist/arena/lenses/index.js +0 -47
  442. package/dist/arena/lenses/product.d.ts +0 -5
  443. package/dist/arena/lenses/product.js +0 -22
  444. package/dist/arena/model-presets.d.ts +0 -23
  445. package/dist/arena/model-presets.js +0 -44
  446. package/dist/arena/phases/adjudication.d.ts +0 -25
  447. package/dist/arena/phases/adjudication.js +0 -142
  448. package/dist/arena/phases/build-consensus.d.ts +0 -30
  449. package/dist/arena/phases/build-consensus.js +0 -85
  450. package/dist/arena/phases/claim-registry.d.ts +0 -26
  451. package/dist/arena/phases/claim-registry.js +0 -60
  452. package/dist/arena/phases/cross-review.d.ts +0 -47
  453. package/dist/arena/phases/cross-review.js +0 -224
  454. package/dist/arena/phases/debate-rounds.d.ts +0 -28
  455. package/dist/arena/phases/debate-rounds.js +0 -161
  456. package/dist/arena/phases/participant-research.d.ts +0 -39
  457. package/dist/arena/phases/participant-research.js +0 -316
  458. package/dist/arena/phases/planning-detail-expansion.d.ts +0 -39
  459. package/dist/arena/phases/planning-detail-expansion.js +0 -120
  460. package/dist/arena/planner.d.ts +0 -28
  461. package/dist/arena/planner.js +0 -312
  462. package/dist/arena/providers/docs.d.ts +0 -7
  463. package/dist/arena/providers/docs.js +0 -111
  464. package/dist/arena/providers/git.d.ts +0 -8
  465. package/dist/arena/providers/git.js +0 -174
  466. package/dist/arena/providers/index.d.ts +0 -32
  467. package/dist/arena/providers/index.js +0 -132
  468. package/dist/arena/providers/none.d.ts +0 -7
  469. package/dist/arena/providers/none.js +0 -11
  470. package/dist/arena/providers/repo.d.ts +0 -7
  471. package/dist/arena/providers/repo.js +0 -258
  472. package/dist/arena/render/session.d.ts +0 -17
  473. package/dist/arena/render/session.js +0 -190
  474. package/dist/arena/render/terminal.d.ts +0 -34
  475. package/dist/arena/render/terminal.js +0 -286
  476. package/dist/arena/strategies/discussion.d.ts +0 -25
  477. package/dist/arena/strategies/discussion.js +0 -143
  478. package/dist/arena/strategies/index.d.ts +0 -15
  479. package/dist/arena/strategies/index.js +0 -28
  480. package/dist/arena/strategies/language-wrapper.d.ts +0 -17
  481. package/dist/arena/strategies/language-wrapper.js +0 -102
  482. package/dist/arena/strategies/lens-wrapper.d.ts +0 -16
  483. package/dist/arena/strategies/lens-wrapper.js +0 -236
  484. package/dist/arena/strategies/planning.d.ts +0 -30
  485. package/dist/arena/strategies/planning.js +0 -225
  486. package/dist/arena/strategies/review.d.ts +0 -26
  487. package/dist/arena/strategies/review.js +0 -168
  488. package/dist/arena/strategies/utils.d.ts +0 -36
  489. package/dist/arena/strategies/utils.js +0 -603
  490. package/dist/arena/tools/selector.d.ts +0 -17
  491. package/dist/arena/tools/selector.js +0 -61
  492. package/dist/arena/transitions.d.ts +0 -53
  493. package/dist/arena/transitions.js +0 -97
  494. package/dist/arena/types.d.ts +0 -518
  495. package/dist/arena/types.js +0 -27
  496. package/dist/cc-orchestrator/agent-adapter.d.ts +0 -54
  497. package/dist/cc-orchestrator/agent-adapter.js +0 -143
  498. package/dist/cc-orchestrator/cc-capability.d.ts +0 -19
  499. package/dist/cc-orchestrator/cc-capability.js +0 -53
  500. package/dist/cc-orchestrator/codex-session-discovery.d.ts +0 -24
  501. package/dist/cc-orchestrator/codex-session-discovery.js +0 -191
  502. package/dist/cc-orchestrator/codex-session-history.d.ts +0 -38
  503. package/dist/cc-orchestrator/codex-session-history.js +0 -247
  504. package/dist/cc-orchestrator/external-agent-bindings.d.ts +0 -27
  505. package/dist/cc-orchestrator/external-agent-bindings.js +0 -150
  506. package/dist/cc-orchestrator/external-agent-changes.d.ts +0 -19
  507. package/dist/cc-orchestrator/external-agent-changes.js +0 -231
  508. package/dist/cc-orchestrator/external-agent-driver.d.ts +0 -19
  509. package/dist/cc-orchestrator/external-agent-driver.js +0 -284
  510. package/dist/cc-orchestrator/external-agent-session-store.d.ts +0 -23
  511. package/dist/cc-orchestrator/external-agent-session-store.js +0 -146
  512. package/dist/cc-orchestrator/index.d.ts +0 -8
  513. package/dist/cc-orchestrator/index.js +0 -8
  514. package/dist/cc-orchestrator/relevance-judge.d.ts +0 -15
  515. package/dist/cc-orchestrator/relevance-judge.js +0 -29
  516. package/dist/cc-orchestrator/session-discovery.d.ts +0 -46
  517. package/dist/cc-orchestrator/session-discovery.js +0 -125
  518. package/dist/cc-orchestrator/session-history.d.ts +0 -54
  519. package/dist/cc-orchestrator/session-history.js +0 -150
  520. package/dist/cron/cron-runtime.d.ts +0 -2
  521. package/dist/cron/cron-runtime.js +0 -2
  522. package/dist/cron/cron-store.d.ts +0 -2
  523. package/dist/cron/cron-store.js +0 -2
  524. package/dist/cron/scheduler.d.ts +0 -7
  525. package/dist/cron/scheduler.js +0 -7
  526. package/dist/engine/goal.js +0 -206
  527. package/dist/external-agents/config.d.ts +0 -2
  528. package/dist/external-agents/config.js +0 -15
  529. package/dist/external-agents/types.d.ts +0 -31
  530. package/dist/git/parse-log.d.ts +0 -13
  531. package/dist/git/parse-log.js +0 -21
  532. package/dist/git/utils.d.ts +0 -49
  533. package/dist/git/utils.js +0 -161
  534. package/dist/git/worktree/crud.d.ts +0 -72
  535. package/dist/git/worktree/crud.js +0 -235
  536. package/dist/git/worktree/diff.d.ts +0 -14
  537. package/dist/git/worktree/diff.js +0 -82
  538. package/dist/git/worktree/git-exec.d.ts +0 -7
  539. package/dist/git/worktree/git-exec.js +0 -87
  540. package/dist/git/worktree/index.d.ts +0 -5
  541. package/dist/git/worktree/index.js +0 -5
  542. package/dist/git/worktree/query.d.ts +0 -43
  543. package/dist/git/worktree/query.js +0 -128
  544. package/dist/git/worktree/slug.d.ts +0 -11
  545. package/dist/git/worktree/slug.js +0 -58
  546. package/dist/git/worktree.d.ts +0 -1
  547. package/dist/git/worktree.js +0 -5
  548. package/dist/lsp/client.d.ts +0 -41
  549. package/dist/lsp/client.js +0 -186
  550. package/dist/lsp/manager.d.ts +0 -40
  551. package/dist/lsp/manager.js +0 -155
  552. package/dist/lsp/root-path.d.ts +0 -9
  553. package/dist/lsp/root-path.js +0 -12
  554. package/dist/lsp/servers.d.ts +0 -16
  555. package/dist/lsp/servers.js +0 -60
  556. package/dist/prompt/sections/coding.md +0 -35
  557. package/dist/quota/credentials.d.ts +0 -3
  558. package/dist/quota/credentials.js +0 -80
  559. package/dist/quota/index.d.ts +0 -36
  560. package/dist/quota/index.js +0 -155
  561. package/dist/quota/types.d.ts +0 -48
  562. package/dist/quota/types.js +0 -13
  563. package/dist/review/review-prompt.d.ts +0 -28
  564. package/dist/review/review-prompt.js +0 -81
  565. package/dist/state.d.ts +0 -160
  566. package/dist/state.js +0 -267
  567. package/dist/tool-system/builtin/apply-patch/applier.d.ts +0 -26
  568. package/dist/tool-system/builtin/apply-patch/applier.js +0 -308
  569. package/dist/tool-system/builtin/apply-patch/backup-targets.d.ts +0 -10
  570. package/dist/tool-system/builtin/apply-patch/backup-targets.js +0 -30
  571. package/dist/tool-system/builtin/apply-patch/index.d.ts +0 -20
  572. package/dist/tool-system/builtin/apply-patch/index.js +0 -105
  573. package/dist/tool-system/builtin/apply-patch/parser.d.ts +0 -17
  574. package/dist/tool-system/builtin/apply-patch/parser.js +0 -212
  575. package/dist/tool-system/builtin/apply-patch/seek-sequence.d.ts +0 -18
  576. package/dist/tool-system/builtin/apply-patch/seek-sequence.js +0 -123
  577. package/dist/tool-system/builtin/apply-patch/types.d.ts +0 -49
  578. package/dist/tool-system/builtin/apply-patch/types.js +0 -13
  579. package/dist/tool-system/builtin/arena.d.ts +0 -31
  580. package/dist/tool-system/builtin/arena.js +0 -416
  581. package/dist/tool-system/builtin/brief.d.ts +0 -6
  582. package/dist/tool-system/builtin/brief.js +0 -41
  583. package/dist/tool-system/builtin/check-quota.d.ts +0 -15
  584. package/dist/tool-system/builtin/check-quota.js +0 -34
  585. package/dist/tool-system/builtin/drive-claude-code.d.ts +0 -51
  586. package/dist/tool-system/builtin/drive-claude-code.js +0 -698
  587. package/dist/tool-system/builtin/lsp.d.ts +0 -7
  588. package/dist/tool-system/builtin/lsp.js +0 -144
  589. package/dist/tool-system/builtin/notebook-edit.d.ts +0 -7
  590. package/dist/tool-system/builtin/notebook-edit.js +0 -127
  591. package/dist/tool-system/builtin/worktree.d.ts +0 -11
  592. package/dist/tool-system/builtin/worktree.js +0 -363
  593. /package/dist/{external-agents → links}/types.js +0 -0
  594. /package/dist/{engine/session-usage.d.ts → session/usage.d.ts} +0 -0
  595. /package/dist/{engine/session-usage.js → session/usage.js} +0 -0
@@ -4,21 +4,150 @@
4
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";
13
15
  // Shared close epochs for SessionManager instances in this process. Concurrent
14
16
  // Engines bind the same epoch; only close advances it. This intentionally does
15
17
  // not claim cross-process/Worker protection.
16
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
+ }
17
145
  const FORK_COPY_EVENT_TYPES = new Set([
18
146
  "message",
19
147
  "tool_use",
20
148
  "tool_result",
21
149
  "summary",
150
+ "context_transfer",
22
151
  "content_replace",
23
152
  "subagent",
24
153
  "external_file_changes",
@@ -72,6 +201,18 @@ export function assertSafeSessionId(sessionId) {
72
201
  throw new SessionError(`invalid session id: too long (max 128 chars)`);
73
202
  }
74
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
+ }
75
216
  /**
76
217
  * Resolve the `.code-shell` home dir. `CODE_SHELL_HOME` overrides the default
77
218
  * `~/.code-shell` — mirrors Codex's `CODEX_HOME`. Tests set it to a temp dir
@@ -82,6 +223,14 @@ export function assertSafeSessionId(sessionId) {
82
223
  export function codeShellHome() {
83
224
  return process.env.CODE_SHELL_HOME || join(homedir(), ".code-shell");
84
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
+ }
85
234
  function isSessionWorkspace(value) {
86
235
  if (!value || typeof value !== "object")
87
236
  return false;
@@ -102,7 +251,16 @@ function isSessionWorkspace(value) {
102
251
  wt.baseRef.length > 0 &&
103
252
  wt.createdBy === "codeshell");
104
253
  }
105
- 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) {
106
264
  if (!existsSync(root))
107
265
  return "no longer exists";
108
266
  let stat;
@@ -116,15 +274,21 @@ async function validateResumeWorktreeRoot(root) {
116
274
  return "is not a valid git worktree (symbolic link)";
117
275
  if (!stat.isDirectory())
118
276
  return "is not a valid git worktree (not a directory)";
119
- if (!(await isGitWorktreeRoot(root)))
120
- return "is not a valid git worktree";
277
+ if (!(await workspaceCapability.validateRoot(root)))
278
+ return "is not a valid workspace root";
121
279
  return null;
122
280
  }
123
281
  export class SessionManager {
124
282
  sessionsDir;
125
283
  registeredCloseEpochs = new Map();
126
- constructor(storageDir) {
127
- this.sessionsDir = storageDir ?? join(codeShellHome(), "sessions");
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);
128
292
  mkdirSync(this.sessionsDir, { recursive: true });
129
293
  this.cleanupStaleForkStaging();
130
294
  }
@@ -178,7 +342,7 @@ export class SessionManager {
178
342
  * nanoid. Either way the on-disk directory is materialized and the
179
343
  * state.json + transcript.jsonl files are written before return.
180
344
  */
181
- create(cwd, model, provider, explicitSessionId, parentSessionId, origin) {
345
+ create(cwd, model, provider, explicitSessionId, parentSessionId, origin, kind = "work") {
182
346
  // External callers may pass any string; nanoid output is trusted. Either
183
347
  // way the ID gets joined into a filesystem path, so the public entry
184
348
  // point validates before that join.
@@ -197,6 +361,8 @@ export class SessionManager {
197
361
  }
198
362
  const state = {
199
363
  sessionId,
364
+ kind,
365
+ stateRevision: 0,
200
366
  cwd,
201
367
  workspace: { root: cwd, kind: "main" },
202
368
  startedAt: Date.now(),
@@ -214,6 +380,7 @@ export class SessionManager {
214
380
  // new top-level session (key present, null) apart from a legacy session
215
381
  // (key absent) and from a sub-agent (key present, non-empty string).
216
382
  parentSessionId: parentSessionId ?? null,
383
+ ...(sessionId.startsWith("qchat-") ? { ephemeral: true } : {}),
217
384
  ...(origin ? { origin } : {}),
218
385
  };
219
386
  // Atomic write (tmp+rename) like saveState, so a crash during this one-time
@@ -229,6 +396,7 @@ export class SessionManager {
229
396
  model,
230
397
  provider,
231
398
  startedAt: state.startedAt,
399
+ kind,
232
400
  });
233
401
  return { state, transcript };
234
402
  }
@@ -250,16 +418,124 @@ export class SessionManager {
250
418
  return existsSync(join(this.sessionsDir, sessionId));
251
419
  }
252
420
  /**
253
- * Cheap "what cwd is this session bound to?" probe — reads only state.json,
254
- * NOT the transcript (unlike resume()). engine.run uses this to recover a
255
- * resumed session's project directory when the caller omits options.cwd
256
- * (e.g. a desktop host whose sidebar repo selection has drifted to null),
257
- * so a project-bound session keeps loading its own agents/settings/memory
258
- * instead of falling back to process.cwd(). Returns undefined for an
259
- * unknown, malformed, or traversal-shaped id rather than throwing — the
260
- * 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.
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.
261
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. */
262
534
  readCwd(sessionId) {
535
+ return this.readSessionMainRoot(sessionId);
536
+ }
537
+ /** Disk-only direct-parent ACL metadata. Undefined means unprovable/corrupt. */
538
+ readParentSessionId(sessionId) {
263
539
  try {
264
540
  assertSafeSessionId(sessionId);
265
541
  }
@@ -271,7 +547,9 @@ export class SessionManager {
271
547
  return undefined;
272
548
  try {
273
549
  const state = JSON.parse(readFileSync(stateFile, "utf-8"));
274
- 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;
275
553
  }
276
554
  catch {
277
555
  return undefined;
@@ -296,9 +574,8 @@ export class SessionManager {
296
574
  const state = JSON.parse(readFileSync(stateFile, "utf-8"));
297
575
  if (isSessionWorkspace(state.workspace))
298
576
  return state.workspace;
299
- return typeof state.cwd === "string" && state.cwd.length > 0
300
- ? { root: state.cwd, kind: "main" }
301
- : undefined;
577
+ const mainRoot = sessionMainRoot(state);
578
+ return mainRoot ? { root: mainRoot, kind: "main" } : undefined;
302
579
  }
303
580
  catch {
304
581
  return undefined;
@@ -314,19 +591,30 @@ export class SessionManager {
314
591
  if (!isSessionWorkspace(workspace)) {
315
592
  throw new SessionError(`invalid workspace for session ${sessionId}`);
316
593
  }
317
- const stateFile = join(this.sessionsDir, sessionId, "state.json");
318
- if (!existsSync(stateFile)) {
319
- 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);
320
600
  }
321
- let state;
601
+ catch {
602
+ return undefined;
603
+ }
604
+ const stateFile = join(this.sessionsDir, sessionId, "state.json");
605
+ if (!existsSync(stateFile))
606
+ return undefined;
322
607
  try {
323
- 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;
324
610
  }
325
- catch (err) {
326
- throw new SessionError(`Session state is corrupt for ${sessionId}: ${err instanceof Error ? err.message : String(err)}`);
611
+ catch {
612
+ return undefined;
327
613
  }
328
- state.workspace = workspace;
329
- this.saveState(state);
614
+ }
615
+ /** Set (number) or clear (undefined) the durable archival marker. */
616
+ setSessionArchived(sessionId, archivedAt) {
617
+ return this.updateSessionState(sessionId, { archivedAt });
330
618
  }
331
619
  recordWorkspaceHandoff(sessionId, from, to) {
332
620
  assertSafeSessionId(sessionId);
@@ -368,9 +656,8 @@ export class SessionManager {
368
656
  catch (err) {
369
657
  throw new SessionError(`Session state is corrupt for ${sessionId}: ${err instanceof Error ? err.message : String(err)}`);
370
658
  }
371
- const legacyMain = typeof state.cwd === "string" && state.cwd.length > 0
372
- ? { root: state.cwd, kind: "main" }
373
- : undefined;
659
+ const mainRoot = sessionMainRoot(state);
660
+ const legacyMain = mainRoot ? { root: mainRoot, kind: "main" } : undefined;
374
661
  const workspace = isSessionWorkspace(state.workspace) ? state.workspace : legacyMain;
375
662
  if (!workspace) {
376
663
  throw new SessionError(`Session ${sessionId} has no recoverable cwd`);
@@ -383,16 +670,28 @@ export class SessionManager {
383
670
  reason: isSessionWorkspace(state.workspace) ? "main" : "legacy",
384
671
  };
385
672
  }
386
- 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);
387
684
  if (!invalidWorktreeReason) {
388
685
  return { ok: true, cwd: workspace.root, workspace, reason: "worktree" };
389
686
  }
390
687
  const branch = workspace.worktree?.branch;
391
- const mainRoot = typeof state.cwd === "string" && state.cwd.length > 0 ? state.cwd : workspace.root;
392
- 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))) {
393
692
  return {
394
693
  ok: false,
395
- cwd: mainRoot,
694
+ cwd: fallbackMainRoot,
396
695
  workspace,
397
696
  reason: "worktree_missing_branch_exists",
398
697
  message: `Session ${sessionId} is bound to worktree ${workspace.root}, but that directory ` +
@@ -401,23 +700,23 @@ export class SessionManager {
401
700
  `back to main explicitly.`,
402
701
  };
403
702
  }
404
- const fallback = { root: mainRoot, kind: "main" };
703
+ const fallback = { root: fallbackMainRoot, kind: "main" };
405
704
  state.workspace = fallback;
406
- this.saveState(state);
705
+ state.stateRevision = this.updateSessionState(sessionId, { workspace: fallback });
407
706
  return {
408
707
  ok: true,
409
- cwd: mainRoot,
708
+ cwd: fallbackMainRoot,
410
709
  workspace: fallback,
411
710
  reason: "worktree_missing_branch_gone",
412
711
  message: `Session ${sessionId} was bound to worktree ${workspace.root}, but that directory ` +
413
712
  `${invalidWorktreeReason}${branch ? ` and branch ${branch} is gone` : ""}; fell back to main ` +
414
- `${mainRoot}. Re-run the request if you want to continue there.`,
713
+ `${fallbackMainRoot}. Re-run the request if you want to continue there.`,
415
714
  };
416
715
  }
417
716
  /**
418
717
  * Cheap "does this session have a persisted goal?" probe — reads only
419
- * state.json, NOT the transcript (like readCwd). A persistent goal lives ONLY
420
- * 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
421
720
  * a session rebuilt from disk (e.g. localStorage wiped) can't recover the
422
721
  * goal from its messages. The desktop host calls this on session load to
423
722
  * re-surface the active-goal block + its Cancel button, which would otherwise
@@ -436,10 +735,18 @@ export class SessionManager {
436
735
  if (!existsSync(stateFile))
437
736
  return undefined;
438
737
  try {
439
- const state = JSON.parse(readFileSync(stateFile, "utf-8"));
440
- return isSameGoalInstance(state.activeGoal, state.goalTerminal)
441
- ? undefined
442
- : 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
+ };
443
750
  }
444
751
  catch {
445
752
  return undefined;
@@ -447,8 +754,9 @@ export class SessionManager {
447
754
  }
448
755
  /**
449
756
  * Disk-only "wipe this session's persistent goal" — the counterpart to
450
- * readActiveGoal. Reads state.json, removes state.activeGoal, and rewrites it
451
- * 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
452
760
  * present (idempotent — clearing a session with no goal is a no-op returning
453
761
  * false). Never throws on an unknown / malformed / traversal-shaped id
454
762
  * (returns false), matching readActiveGoal's tolerance.
@@ -461,28 +769,107 @@ export class SessionManager {
461
769
  * Engine.clearGoal also delegates its disk write here so the wipe logic lives
462
770
  * in exactly one place.
463
771
  */
464
- clearActiveGoal(sessionId) {
772
+ clearActiveGoal(sessionId, expected) {
465
773
  try {
466
774
  assertSafeSessionId(sessionId);
467
775
  }
468
776
  catch {
469
777
  return false;
470
778
  }
471
- const stateFile = join(this.sessionsDir, sessionId, "state.json");
472
- if (!existsSync(stateFile))
473
- return false;
474
- 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) {
475
820
  try {
476
- state = JSON.parse(readFileSync(stateFile, "utf-8"));
821
+ assertSafeSessionId(sessionId);
477
822
  }
478
823
  catch {
479
- return false;
824
+ return undefined;
480
825
  }
481
- if (state.activeGoal === undefined)
482
- return false;
483
- state.activeGoal = undefined;
484
- this.saveState(state);
485
- 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;
486
873
  }
487
874
  resume(sessionId) {
488
875
  assertSafeSessionId(sessionId);
@@ -496,7 +883,7 @@ export class SessionManager {
496
883
  }
497
884
  let state;
498
885
  try {
499
- state = JSON.parse(readFileSync(stateFile, "utf-8"));
886
+ state = hydrateGoalLifecycle(JSON.parse(readFileSync(stateFile, "utf-8")));
500
887
  }
501
888
  catch (err) {
502
889
  // A corrupt state.json (external tampering, disk corruption, or a crash
@@ -506,89 +893,402 @@ export class SessionManager {
506
893
  }
507
894
  const transcriptFile = join(sessionDir, "transcript.jsonl");
508
895
  const transcript = Transcript.loadFromFile(transcriptFile);
896
+ state.kind = normalizedSessionKind(state.kind);
509
897
  state.status = "active";
898
+ delete state.lastCompletionKind;
510
899
  Object.assign(state, normalizeCumulativeUsageCounters(state, state.tokenUsage));
511
900
  return { state, transcript };
512
901
  }
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
+ }
513
927
  /**
514
928
  * Merge a field-level state update into the latest persisted snapshot.
515
929
  *
516
930
  * Unlike saveState(), callers do not supply a potentially stale whole-state
517
- * object. The read, shallow merge, and atomic write are synchronous, so no
518
- * other callback on this process's event loop can interleave between them.
519
- * This is deliberately not a session-level lock. Engine.run rejects re-entry
520
- * on one Engine (G6), and the generation check in saveState rejects an older
521
- * registered Engine in this process. Workers and separate processes remain
522
- * an unresolved finding requiring locking/CAS.
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.
523
933
  */
524
934
  updateSessionState(sessionId, partial) {
525
935
  assertSafeSessionId(sessionId);
526
- const stateFile = join(this.sessionsDir, sessionId, "state.json");
527
- if (!existsSync(stateFile)) {
528
- throw new SessionError(`Session state file not found: ${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.
529
959
  }
530
- let state;
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) {
531
1145
  try {
532
- state = JSON.parse(readFileSync(stateFile, "utf-8"));
1146
+ const stateRevision = this.updateSessionState(state.sessionId, partial);
1147
+ Object.assign(state, partial, { stateRevision });
1148
+ return true;
533
1149
  }
534
- catch (err) {
535
- throw new SessionError(`Session state is corrupt for ${sessionId}: ${err instanceof Error ? err.message : String(err)}`);
1150
+ catch {
1151
+ return false;
536
1152
  }
537
- Object.assign(state, partial);
538
- state.sessionId = sessionId;
539
- this.saveState(state);
540
1153
  }
541
- saveState(state, generation) {
1154
+ saveStateAttempt(state, generation) {
542
1155
  // state.sessionId could come from a deserialized state.json that was
543
1156
  // tampered with on disk. Validate before joining.
544
1157
  assertSafeSessionId(state.sessionId);
1158
+ try {
1159
+ hydrateGoalLifecycle(state);
1160
+ }
1161
+ catch {
1162
+ return { ok: false, reason: "goal_schema_conflict" };
1163
+ }
545
1164
  const writerGeneration = generation ?? this.registeredCloseEpochs.get(state.sessionId);
546
1165
  if (writerGeneration !== undefined &&
547
1166
  (currentSessionCloseEpochs.get(this.generationKey(state.sessionId)) ?? 0) !== writerGeneration) {
548
- // A closing/old Engine may finish asynchronously after its generation was
549
- // invalidated. Reject explicitly without throwing into an unobserved
550
- // engine finally path, and most importantly without touching the disk.
551
- return false;
1167
+ return { ok: false, reason: "generation_conflict" };
552
1168
  }
553
1169
  const sessionDir = join(this.sessionsDir, state.sessionId);
554
1170
  mkdirSync(sessionDir, { recursive: true });
555
- // Atomic file replacement: stage to .tmp, then rename, preventing a torn
556
- // state.json. Registered Engines share one epoch until close advances it,
557
- // so concurrent opens are not fenced from each other. Workers, separate
558
- // processes, and their stale-writer ordering still require locking/CAS.
559
1171
  const target = join(sessionDir, "state.json");
560
- // Preserve the newest goal tombstone across whole-state writers. If an old
561
- // detached bundle still carries the tombstoned goal, drop it before write;
562
- // when disk already contains a newer replacement goal, retain that goal as
563
- // well instead of letting the stale bundle erase it.
564
- let persisted;
565
- 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 {
566
1213
  try {
567
- persisted = JSON.parse(readFileSync(target, "utf-8"));
1214
+ release();
568
1215
  }
569
1216
  catch {
570
- // The atomic writer should make this rare. Preserve the existing
571
- // behavior and overwrite malformed state with the caller's snapshot.
1217
+ // A compromised/reaped lease must not mask the persistence result.
572
1218
  }
573
1219
  }
574
- // A title is generated after the first run and may be merged while the
575
- // next run still owns a state object loaded before that title existed.
576
- // Preserve the newly persisted title when such a stale whole-state writer
577
- // saves later; assigning title = undefined explicitly still clears it.
578
- if (persisted?.title !== undefined && !("title" in state)) {
579
- state.title = persisted.title;
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
+ }
580
1248
  }
581
- const terminal = newestGoalTerminal(state.goalTerminal, persisted?.goalTerminal);
582
- if (terminal)
583
- state.goalTerminal = terminal;
584
- if (terminal && isSameGoalInstance(state.activeGoal, terminal)) {
585
- const diskGoal = persisted?.activeGoal;
586
- state.activeGoal = diskGoal && !isSameGoalInstance(diskGoal, terminal) ? diskGoal : undefined;
1249
+ return undefined;
1250
+ }
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;
587
1272
  }
588
- const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
589
- writeFileSync(tmp, JSON.stringify(state, null, 2), "utf-8");
590
- renameSync(tmp, target);
591
- return true;
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);
592
1292
  }
593
1293
  generationKey(sessionId) {
594
1294
  return `${this.sessionsDir}\0${sessionId}`;
@@ -598,7 +1298,10 @@ export class SessionManager {
598
1298
  assertSafeSessionId(sourceSessionId);
599
1299
  if (options.targetSessionId !== undefined)
600
1300
  assertSafeSessionId(options.targetSessionId);
601
- const snapshot = this.readForkSnapshot(sourceSessionId, options.throughEventId);
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");
602
1305
  const targetSessionId = options.targetSessionId ?? nanoid(16);
603
1306
  const createdAt = Date.now();
604
1307
  const lineage = {
@@ -610,11 +1313,70 @@ export class SessionManager {
610
1313
  createdAt,
611
1314
  };
612
1315
  const state = buildForkState(snapshot.sourceState, targetSessionId, lineage, createdAt);
1316
+ if (options.ephemeral === true)
1317
+ state.ephemeral = true;
613
1318
  const events = buildForkTranscript(snapshot.copiedEvents, state);
614
1319
  const bundle = this.publishSessionAtomically(targetSessionId, state, events);
615
1320
  return { bundle, lineage, copiedEventCount: snapshot.copiedEvents.length };
616
1321
  }
617
- readForkSnapshot(sourceSessionId, throughEventId) {
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");
1345
+ const source = this.resume(sourceSessionId);
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) {
618
1380
  const sessionDir = join(this.sessionsDir, sourceSessionId);
619
1381
  const stateFile = join(sessionDir, "state.json");
620
1382
  const transcriptFile = join(sessionDir, "transcript.jsonl");
@@ -633,12 +1395,26 @@ export class SessionManager {
633
1395
  }
634
1396
  const sourceEvents = structuredClone(parsed.events);
635
1397
  let frozen = sourceEvents;
636
- if (throughEventId !== undefined) {
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) {
637
1411
  const matches = sourceEvents
638
- .map((event, index) => (event.id === throughEventId ? index : -1))
1412
+ .map((event, index) => (event.id === effectiveCursor ? index : -1))
639
1413
  .filter((index) => index >= 0);
640
1414
  if (matches.length !== 1) {
641
- throw new SessionError(`Fork cursor must identify exactly one source event`);
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`);
642
1418
  }
643
1419
  const cursor = sourceEvents[matches[0]];
644
1420
  if (cursor.type === "session_meta") {
@@ -687,11 +1463,16 @@ export class SessionManager {
687
1463
  rmSync(stagingDir, { recursive: true, force: true });
688
1464
  }
689
1465
  }
690
- list(limit = 20) {
1466
+ list(limit = 20, opts) {
691
1467
  if (!existsSync(this.sessionsDir))
692
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"];
693
1474
  const dirs = readdirSync(this.sessionsDir, { withFileTypes: true })
694
- .filter((d) => d.isDirectory() && !d.name.startsWith(".pending-"))
1475
+ .filter((d) => d.isDirectory() && !d.name.startsWith(".pending-") && !d.name.startsWith("qchat-"))
695
1476
  .map((d) => d.name);
696
1477
  const candidates = [];
697
1478
  for (const dir of dirs) {
@@ -718,11 +1499,17 @@ export class SessionManager {
718
1499
  candidates.push({ dir, lastActiveAt, transcriptFile, stateFile, transcriptExists });
719
1500
  }
720
1501
  candidates.sort((a, b) => b.lastActiveAt - a.lastActiveAt);
721
- const top = candidates.slice(0, limit);
722
1502
  const sessions = [];
723
- for (const c of top) {
1503
+ for (const c of candidates) {
1504
+ if (sessions.length >= limit)
1505
+ break;
724
1506
  try {
725
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;
726
1513
  const preview = c.transcriptExists ? readLastUserMessage(c.transcriptFile) : undefined;
727
1514
  sessions.push({ ...state, preview, lastActiveAt: c.lastActiveAt });
728
1515
  }
@@ -739,6 +1526,8 @@ export function buildForkState(source, targetSessionId, lineage, startedAt = Dat
739
1526
  : { root: source.cwd, kind: "main" };
740
1527
  return {
741
1528
  sessionId: targetSessionId,
1529
+ kind: "work",
1530
+ stateRevision: 0,
742
1531
  cwd: source.cwd,
743
1532
  workspace,
744
1533
  startedAt,
@@ -880,15 +1669,6 @@ function validateForkToolPairs(events) {
880
1669
  throw new SessionError(`Fork tool metadata does not match provider history`);
881
1670
  }
882
1671
  }
883
- function newestGoalTerminal(incoming, persisted) {
884
- if (!incoming)
885
- return persisted;
886
- if (!persisted)
887
- return incoming;
888
- const incomingAt = incoming.terminatedAtMs ?? Number.NEGATIVE_INFINITY;
889
- const persistedAt = persisted.terminatedAtMs ?? Number.NEGATIVE_INFINITY;
890
- return incomingAt > persistedAt ? incoming : persisted;
891
- }
892
1672
  /**
893
1673
  * Scan a transcript.jsonl for the LAST user message and return a short
894
1674
  * preview, reading the file from the END in 64 KiB chunks.