@gajae-code/coding-agent 0.14.2 → 0.15.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 (345) hide show
  1. package/CHANGELOG.md +120 -1
  2. package/dist/types/async/job-manager.d.ts +4 -1
  3. package/dist/types/autoresearch/dashboard.d.ts +29 -4
  4. package/dist/types/autoresearch/data-context.d.ts +25 -0
  5. package/dist/types/autoresearch/git.d.ts +65 -7
  6. package/dist/types/autoresearch/harness.d.ts +58 -0
  7. package/dist/types/autoresearch/prompts.d.ts +51 -0
  8. package/dist/types/autoresearch/runs.d.ts +173 -0
  9. package/dist/types/autoresearch/types.d.ts +9 -139
  10. package/dist/types/capability/types.d.ts +20 -0
  11. package/dist/types/cli/accounts-cli.d.ts +1 -0
  12. package/dist/types/commands/{rlm.d.ts → autoresearch.d.ts} +2 -4
  13. package/dist/types/config/autorouting-contract.d.ts +146 -0
  14. package/dist/types/config/autorouting-generator.d.ts +40 -0
  15. package/dist/types/config/autorouting-tier-map.d.ts +16844 -0
  16. package/dist/types/config/autorouting.d.ts +35 -0
  17. package/dist/types/config/model-discovery-manager.d.ts +8 -1
  18. package/dist/types/config/model-registry.d.ts +30 -1
  19. package/dist/types/config/provider-selection-policy.d.ts +23 -0
  20. package/dist/types/config/settings-schema.d.ts +61 -3
  21. package/dist/types/config/settings.d.ts +3 -0
  22. package/dist/types/coordinator/contract.d.ts +1 -1
  23. package/dist/types/coordinator-mcp/durability.d.ts +29 -0
  24. package/dist/types/coordinator-mcp/event-webhook.d.ts +50 -0
  25. package/dist/types/coordinator-mcp/question-state.d.ts +1 -1
  26. package/dist/types/coordinator-mcp/server.d.ts +14 -0
  27. package/dist/types/crash/index-store.d.ts +3 -0
  28. package/dist/types/crash/record-loader.d.ts +2 -0
  29. package/dist/types/crash/upstream/relay.d.ts +5 -3
  30. package/dist/types/cursor.d.ts +12 -0
  31. package/dist/types/defaults/gjc-defaults.d.ts +35 -1
  32. package/dist/types/defaults/gjc-skills.generated.d.ts +1 -1
  33. package/dist/types/discovery/helpers.d.ts +1 -1
  34. package/dist/types/edit/index.d.ts +2 -1
  35. package/dist/types/eval/py/executor.d.ts +2 -0
  36. package/dist/types/extensibility/extensions/loader.d.ts +32 -0
  37. package/dist/types/extensibility/gjc-plugins/types.d.ts +1 -1
  38. package/dist/types/extensibility/skills.d.ts +2 -0
  39. package/dist/types/gjc-runtime/autoresearch-runtime.d.ts +194 -0
  40. package/dist/types/gjc-runtime/gc-runtime.d.ts +1 -1
  41. package/dist/types/gjc-runtime/psmux-detect.d.ts +1 -1
  42. package/dist/types/gjc-runtime/python-transcript.d.ts +19 -0
  43. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +2 -8
  44. package/dist/types/gjc-runtime/session-layout.d.ts +6 -3
  45. package/dist/types/gjc-runtime/state-renderer.d.ts +0 -22
  46. package/dist/types/gjc-runtime/state-schema.d.ts +7 -7
  47. package/dist/types/gjc-runtime/tmux-common.d.ts +2 -2
  48. package/dist/types/gjc-runtime/ultragoal-change-set.d.ts +38 -1
  49. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +2 -24
  50. package/dist/types/gjc-runtime/ultragoal-validation-policy.d.ts +73 -0
  51. package/dist/types/gjc-runtime/workflow-recovery-projection.d.ts +100 -0
  52. package/dist/types/hooks/skill-keywords.d.ts +1 -1
  53. package/dist/types/main.d.ts +0 -10
  54. package/dist/types/modes/acp/acp-agent.d.ts +13 -6
  55. package/dist/types/modes/components/model-selector.d.ts +14 -0
  56. package/dist/types/modes/components/skill-hud/render.d.ts +6 -1
  57. package/dist/types/modes/components/smart-routing-panel.d.ts +82 -0
  58. package/dist/types/modes/controllers/selector-controller.d.ts +14 -0
  59. package/dist/types/modes/interactive-mode.d.ts +3 -0
  60. package/dist/types/modes/types.d.ts +9 -0
  61. package/dist/types/rlm/artifacts.d.ts +4 -4
  62. package/dist/types/runtime/optional-runtime-services.d.ts +2 -1
  63. package/dist/types/runtime/workspace-tree-service.d.ts +5 -1
  64. package/dist/types/runtime-mcp/config.d.ts +6 -0
  65. package/dist/types/runtime-mcp/manager.d.ts +10 -0
  66. package/dist/types/sdk/acp/adapter.d.ts +0 -4
  67. package/dist/types/sdk/broker/broker.d.ts +1 -1
  68. package/dist/types/sdk/broker/lifecycle.d.ts +57 -2
  69. package/dist/types/sdk/broker/session-index.d.ts +15 -0
  70. package/dist/types/sdk/bus/chat-daemon-control.d.ts +6 -0
  71. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +4 -0
  72. package/dist/types/sdk/bus/index.d.ts +8 -0
  73. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +2 -0
  74. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +12 -2
  75. package/dist/types/sdk/client/client.d.ts +39 -4
  76. package/dist/types/sdk/host/host.d.ts +1 -0
  77. package/dist/types/sdk/host/internal-autorouting-state.d.ts +3 -0
  78. package/dist/types/sdk/host/model-pin.d.ts +29 -0
  79. package/dist/types/sdk/host/session-runtime.d.ts +2 -1
  80. package/dist/types/sdk/lifecycle-session.d.ts +13 -0
  81. package/dist/types/sdk/router/session-router.d.ts +27 -1
  82. package/dist/types/sdk/session.d.ts +11 -1
  83. package/dist/types/session/agent-session.d.ts +36 -6
  84. package/dist/types/session/artifacts.d.ts +16 -1
  85. package/dist/types/session/internal/managed-session-storage.d.ts +7 -0
  86. package/dist/types/session/session-manager.d.ts +84 -2
  87. package/dist/types/session/session-staging-paths.d.ts +3 -0
  88. package/dist/types/skill-state/canonical-skills.d.ts +1 -1
  89. package/dist/types/skill-state/workflow-hud.d.ts +12 -21
  90. package/dist/types/skill-state/workflow-mutation-guard.d.ts +1 -0
  91. package/dist/types/slash-commands/helpers/autorouting-status.d.ts +8 -0
  92. package/dist/types/system-prompt.d.ts +23 -0
  93. package/dist/types/task/executor.d.ts +35 -4
  94. package/dist/types/task/index.d.ts +17 -3
  95. package/dist/types/task/receipt.d.ts +3 -1
  96. package/dist/types/task/types.d.ts +144 -0
  97. package/dist/types/tools/atomic-file-write.d.ts +72 -0
  98. package/dist/types/tools/descriptors.d.ts +1 -0
  99. package/dist/types/tools/index.d.ts +36 -1
  100. package/dist/types/tools/move-session.d.ts +40 -0
  101. package/dist/types/tools/python.d.ts +12 -0
  102. package/dist/types/tools/resource-gc.d.ts +0 -3
  103. package/dist/types/tools/search.d.ts +8 -1
  104. package/dist/types/tools/skill.d.ts +9 -4
  105. package/dist/types/tools/subagent.d.ts +33 -3
  106. package/dist/types/utils/xml-escape.d.ts +2 -0
  107. package/dist/types/workflow/workflow-intent-diff.d.ts +2 -2
  108. package/dist/types/workflow/workflow-intent-report-consumer.d.ts +1 -1
  109. package/package.json +11 -12
  110. package/scripts/build-sdk-package-smoke.ts +7 -5
  111. package/scripts/check-autorouting-tier-map.ts +183 -0
  112. package/scripts/generate-sdk-operation-inventory.ts +12 -0
  113. package/scripts/generate-tool-catalog.ts +40 -5
  114. package/src/async/job-manager.ts +5 -2
  115. package/src/autoresearch/dashboard.ts +134 -314
  116. package/src/autoresearch/data-context.ts +35 -0
  117. package/src/autoresearch/git.ts +186 -26
  118. package/src/autoresearch/{helpers.ts → harness.ts} +65 -40
  119. package/src/autoresearch/{prompt-setup.md → prompts/prompt-setup.md} +7 -7
  120. package/src/autoresearch/prompts/prompt.md +103 -0
  121. package/src/autoresearch/prompts.ts +126 -0
  122. package/src/autoresearch/runs.ts +708 -0
  123. package/src/autoresearch/types.ts +9 -150
  124. package/src/capability/index.ts +4 -4
  125. package/src/capability/types.ts +20 -0
  126. package/src/cli/accounts-cli.ts +21 -5
  127. package/src/cli/config-cli.ts +25 -1
  128. package/src/cli/fast-help.ts +1 -2
  129. package/src/cli/list-models.ts +5 -8
  130. package/src/cli/setup-cli.ts +8 -0
  131. package/src/cli.ts +2 -21
  132. package/src/commands/autoresearch.ts +33 -0
  133. package/src/commands/coordinator.ts +15 -0
  134. package/src/commands/launch.ts +2 -4
  135. package/src/commands/quick-lane.ts +1 -1
  136. package/src/commands/sdk.ts +24 -0
  137. package/src/commands/state.ts +1 -1
  138. package/src/config/autorouting-contract.ts +474 -0
  139. package/src/config/autorouting-generator.ts +238 -0
  140. package/src/config/autorouting-tier-map.ts +6108 -0
  141. package/src/config/autorouting.ts +120 -0
  142. package/src/config/model-discovery-manager.ts +42 -11
  143. package/src/config/model-registry.ts +348 -68
  144. package/src/config/provider-selection-policy.ts +60 -9
  145. package/src/config/settings-schema.ts +195 -6
  146. package/src/config/settings.ts +62 -1
  147. package/src/config.ts +2 -3
  148. package/src/coordinator/contract.ts +0 -1
  149. package/src/coordinator-mcp/codex-handoff.ts +68 -42
  150. package/src/coordinator-mcp/durability.ts +200 -0
  151. package/src/coordinator-mcp/event-webhook.ts +278 -0
  152. package/src/coordinator-mcp/question-state.ts +109 -46
  153. package/src/coordinator-mcp/server.ts +932 -504
  154. package/src/crash/index-store.ts +126 -22
  155. package/src/crash/record-loader.ts +7 -0
  156. package/src/crash/upstream/relay.ts +82 -34
  157. package/src/cursor.ts +84 -1
  158. package/src/debug/index.ts +26 -0
  159. package/src/defaults/gjc/skills/autoresearch/SKILL.md +116 -0
  160. package/src/defaults/gjc/skills/autoresearch/auto-critic.md +46 -0
  161. package/src/defaults/gjc/skills/autoresearch/auto-iterate.md +42 -0
  162. package/src/defaults/gjc/skills/deep-interview/SKILL.md +17 -37
  163. package/src/defaults/gjc/skills/ralplan/SKILL.md +21 -18
  164. package/src/defaults/gjc/skills/ultragoal/SKILL.md +6 -17
  165. package/src/defaults/gjc/skills/ultragoal/ai-slop-cleaner.md +2 -2
  166. package/src/defaults/gjc-defaults.ts +89 -2
  167. package/src/defaults/gjc-skills.generated.ts +24 -17
  168. package/src/discovery/builtin.ts +18 -5
  169. package/src/discovery/helpers.ts +6 -6
  170. package/src/edit/index.ts +5 -2
  171. package/src/eval/py/executor.ts +8 -0
  172. package/src/extensibility/extensions/loader.ts +135 -13
  173. package/src/extensibility/extensions/wrapper.ts +1 -2
  174. package/src/extensibility/gjc-plugins/schema.ts +1 -1
  175. package/src/extensibility/runtime-skill-discovery.ts +2 -2
  176. package/src/extensibility/skill-management.ts +2 -3
  177. package/src/extensibility/skills.ts +16 -8
  178. package/src/gjc-runtime/autoresearch-runtime.ts +1509 -0
  179. package/src/gjc-runtime/gc-render.ts +0 -1
  180. package/src/gjc-runtime/gc-runtime.ts +1 -11
  181. package/src/gjc-runtime/launch-tmux.ts +3 -3
  182. package/src/gjc-runtime/psmux-detect.ts +2 -2
  183. package/src/gjc-runtime/python-transcript.ts +58 -0
  184. package/src/gjc-runtime/ralplan-runtime.ts +6 -18
  185. package/src/gjc-runtime/session-layout.ts +18 -7
  186. package/src/gjc-runtime/session-state-sidecar.ts +58 -9
  187. package/src/gjc-runtime/state-renderer.ts +0 -38
  188. package/src/gjc-runtime/state-runtime.ts +43 -22
  189. package/src/gjc-runtime/state-schema.ts +1 -1
  190. package/src/gjc-runtime/tmux-common.ts +7 -7
  191. package/src/gjc-runtime/ultragoal-change-set.ts +232 -11
  192. package/src/gjc-runtime/ultragoal-runtime.ts +309 -114
  193. package/src/gjc-runtime/ultragoal-validation-policy.ts +247 -0
  194. package/src/gjc-runtime/workflow-command-ref.ts +15 -11
  195. package/src/gjc-runtime/workflow-manifest.generated.json +413 -532
  196. package/src/gjc-runtime/workflow-manifest.ts +43 -83
  197. package/src/gjc-runtime/workflow-recovery-projection.ts +616 -0
  198. package/src/hooks/skill-keywords.ts +3 -3
  199. package/src/hooks/skill-state.ts +2 -2
  200. package/src/internal-urls/docs-index.generated.ts +26 -24
  201. package/src/lsp/index.ts +25 -5
  202. package/src/main.ts +2 -27
  203. package/src/modes/acp/acp-agent.ts +200 -75
  204. package/src/modes/components/model-selector.ts +216 -13
  205. package/src/modes/components/oauth-selector.ts +5 -5
  206. package/src/modes/components/skill-hud/render.ts +173 -35
  207. package/src/modes/components/smart-routing-panel.ts +531 -0
  208. package/src/modes/components/tool-status-header.ts +6 -3
  209. package/src/modes/controllers/command-controller.ts +17 -7
  210. package/src/modes/controllers/input-controller.ts +44 -9
  211. package/src/modes/controllers/runtime-mcp-command-controller.ts +4 -0
  212. package/src/modes/controllers/selector-controller.ts +298 -65
  213. package/src/modes/interactive-mode.ts +8 -1
  214. package/src/modes/shared/agent-wire/workflow-gate-types.ts +1 -1
  215. package/src/modes/types.ts +9 -1
  216. package/src/prompts/agents/planner.md +1 -1
  217. package/src/prompts/system/system-prompt.md +1 -1
  218. package/src/prompts/system/volatile-project-context.md +2 -1
  219. package/src/prompts/tools/move-session.md +15 -0
  220. package/src/prompts/tools/python.md +12 -0
  221. package/src/prompts/tools/skill-discovery.md +1 -1
  222. package/src/prompts/tools/skill.md +2 -2
  223. package/src/prompts/tools/task-summary.md +1 -0
  224. package/src/prompts/tools/task.md +6 -0
  225. package/src/rlm/artifacts.ts +12 -10
  226. package/src/runtime/optional-runtime-services.ts +3 -2
  227. package/src/runtime/workspace-tree-service.ts +11 -3
  228. package/src/runtime-mcp/config.ts +8 -1
  229. package/src/runtime-mcp/manager.ts +33 -0
  230. package/src/sdk/acp/adapter.ts +48 -1
  231. package/src/sdk/broker/broker.ts +30 -2
  232. package/src/sdk/broker/discovery.ts +217 -2
  233. package/src/sdk/broker/lifecycle.ts +385 -59
  234. package/src/sdk/broker/session-index.ts +243 -48
  235. package/src/sdk/broker/transport.ts +1 -0
  236. package/src/sdk/bus/chat-daemon-control.ts +8 -2
  237. package/src/sdk/bus/chat-daemon-runtime.ts +5 -0
  238. package/src/sdk/bus/index.ts +208 -11
  239. package/src/sdk/bus/kind-aware-reconciliation.ts +16 -0
  240. package/src/sdk/bus/reconciliation-store.ts +27 -5
  241. package/src/sdk/bus/telegram-daemon-contract.ts +12 -2
  242. package/src/sdk/client/client.ts +204 -22
  243. package/src/sdk/host/host.ts +16 -0
  244. package/src/sdk/host/internal-autorouting-state.ts +13 -0
  245. package/src/sdk/host/model-pin.ts +101 -0
  246. package/src/sdk/host/reverse-leases.ts +2 -1
  247. package/src/sdk/host/session-runtime.test.ts +224 -1
  248. package/src/sdk/host/session-runtime.ts +125 -37
  249. package/src/sdk/lifecycle/service.ts +3 -1
  250. package/src/sdk/lifecycle-session.ts +42 -1
  251. package/src/sdk/protocol/operation-inventory.generated.json +66 -0
  252. package/src/sdk/router/session-router.ts +375 -36
  253. package/src/sdk/session.ts +547 -47
  254. package/src/session/account-inventory.ts +1 -1
  255. package/src/session/agent-session.ts +765 -189
  256. package/src/session/artifacts.ts +234 -4
  257. package/src/session/internal/managed-session-storage.ts +91 -4
  258. package/src/session/provider-safety-stop-hint.ts +3 -2
  259. package/src/session/session-manager.ts +775 -16
  260. package/src/session/session-staging-paths.ts +8 -0
  261. package/src/setup/hermes/templates/operator-instructions.v1.md +0 -1
  262. package/src/setup/host-plugin-setup.ts +2 -2
  263. package/src/skill-state/active-state.ts +2 -2
  264. package/src/skill-state/canonical-skills.ts +1 -1
  265. package/src/skill-state/initial-phase.ts +1 -1
  266. package/src/skill-state/workflow-hud.ts +41 -41
  267. package/src/skill-state/workflow-mutation-guard.ts +26 -7
  268. package/src/slash-commands/builtin-registry.ts +77 -0
  269. package/src/slash-commands/helpers/autorouting-status.ts +55 -0
  270. package/src/system-prompt.ts +102 -5
  271. package/src/task/discovery.ts +2 -3
  272. package/src/task/executor.ts +617 -43
  273. package/src/task/index.ts +262 -15
  274. package/src/task/receipt.ts +30 -0
  275. package/src/task/render.ts +11 -1
  276. package/src/task/types.ts +316 -0
  277. package/src/tools/atomic-file-write.ts +594 -0
  278. package/src/tools/bisect.ts +3 -2
  279. package/src/tools/descriptors.test.ts +3 -0
  280. package/src/tools/descriptors.ts +24 -0
  281. package/src/tools/image-gen.ts +5 -1
  282. package/src/tools/index.ts +62 -5
  283. package/src/tools/move-session.ts +107 -0
  284. package/src/tools/python.ts +205 -0
  285. package/src/tools/read.ts +88 -15
  286. package/src/tools/renderers.ts +2 -0
  287. package/src/tools/resource-gc.ts +8 -177
  288. package/src/tools/search.ts +30 -6
  289. package/src/tools/skill-discovery.ts +1 -0
  290. package/src/tools/skill.ts +37 -7
  291. package/src/tools/subagent-render.ts +119 -13
  292. package/src/tools/subagent.ts +91 -74
  293. package/src/tools/tool-catalog.generated.ts +64 -2
  294. package/src/tools/write.ts +16 -12
  295. package/src/utils/xml-escape.ts +14 -0
  296. package/src/web/search/providers/codex.ts +2 -2
  297. package/src/workflow/workflow-intent-diff.ts +13 -12
  298. package/src/workflow/workflow-intent-report-consumer.ts +6 -6
  299. package/dist/types/autoresearch/helpers.d.ts +0 -24
  300. package/dist/types/autoresearch/index.d.ts +0 -2
  301. package/dist/types/autoresearch/state.d.ts +0 -17
  302. package/dist/types/autoresearch/storage.d.ts +0 -142
  303. package/dist/types/autoresearch/tools/init-experiment.d.ts +0 -31
  304. package/dist/types/autoresearch/tools/log-experiment.d.ts +0 -23
  305. package/dist/types/autoresearch/tools/run-experiment.d.ts +0 -8
  306. package/dist/types/autoresearch/tools/update-notes.d.ts +0 -12
  307. package/dist/types/commands/team.d.ts +0 -24
  308. package/dist/types/gjc-runtime/team-gc.d.ts +0 -7
  309. package/dist/types/gjc-runtime/team-launch.d.ts +0 -61
  310. package/dist/types/gjc-runtime/team-notify.d.ts +0 -45
  311. package/dist/types/gjc-runtime/team-runtime.d.ts +0 -336
  312. package/dist/types/gjc-runtime/team-store.d.ts +0 -168
  313. package/dist/types/gjc-runtime/team-worker-heartbeat.d.ts +0 -43
  314. package/dist/types/gjc-runtime/team-worker-memory-guard.d.ts +0 -92
  315. package/dist/types/gjc-runtime/team-workers.d.ts +0 -83
  316. package/dist/types/rlm/index.d.ts +0 -47
  317. package/dist/types/rlm/preset.d.ts +0 -23
  318. package/dist/types/rlm/python-tool.d.ts +0 -16
  319. package/src/autoresearch/command-resume.md +0 -14
  320. package/src/autoresearch/index.ts +0 -536
  321. package/src/autoresearch/prompt.md +0 -103
  322. package/src/autoresearch/resume-message.md +0 -10
  323. package/src/autoresearch/state.ts +0 -273
  324. package/src/autoresearch/storage.ts +0 -699
  325. package/src/autoresearch/tools/init-experiment.ts +0 -272
  326. package/src/autoresearch/tools/log-experiment.ts +0 -524
  327. package/src/autoresearch/tools/run-experiment.ts +0 -475
  328. package/src/autoresearch/tools/update-notes.ts +0 -109
  329. package/src/commands/rlm.ts +0 -19
  330. package/src/commands/team.ts +0 -238
  331. package/src/defaults/gjc/skills/deep-interview/auto-research-greenfield.md +0 -42
  332. package/src/defaults/gjc/skills/team/SKILL.md +0 -467
  333. package/src/gjc-runtime/team-gc.ts +0 -49
  334. package/src/gjc-runtime/team-launch.ts +0 -404
  335. package/src/gjc-runtime/team-notify.ts +0 -326
  336. package/src/gjc-runtime/team-runtime.ts +0 -5923
  337. package/src/gjc-runtime/team-store.ts +0 -878
  338. package/src/gjc-runtime/team-worker-heartbeat.ts +0 -149
  339. package/src/gjc-runtime/team-worker-memory-guard.ts +0 -363
  340. package/src/gjc-runtime/team-workers.ts +0 -1123
  341. package/src/prompts/system/rlm-report-command.md +0 -1
  342. package/src/prompts/system/rlm-research.md +0 -23
  343. package/src/rlm/index.ts +0 -365
  344. package/src/rlm/preset.ts +0 -76
  345. package/src/rlm/python-tool.ts +0 -68
@@ -1,3 +1,4 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
1
2
  import * as crypto from "node:crypto";
2
3
  import * as fs from "node:fs";
3
4
 
@@ -21,6 +22,13 @@ import type * as native from "@gajae-code/natives";
21
22
  function nativeSessionManager(): typeof import("@gajae-code/natives") {
22
23
  return require("@gajae-code/natives") as typeof import("@gajae-code/natives");
23
24
  }
25
+ const cwdTransitionAls = new AsyncLocalStorage<symbol>();
26
+ type CwdReadLeaseContext = { active: boolean; owner: symbol };
27
+ const cwdReadLeaseAls = new AsyncLocalStorage<CwdReadLeaseContext>();
28
+ const CWD_NOFOLLOW_OPEN_FLAGS =
29
+ fs.constants.O_RDONLY |
30
+ (typeof fs.constants.O_DIRECTORY === "number" ? fs.constants.O_DIRECTORY : 0) |
31
+ (process.platform === "win32" ? 0 : (fs.constants.O_NOFOLLOW ?? 0));
24
32
 
25
33
  import { getTerminalId } from "@gajae-code/tui";
26
34
  import {
@@ -194,6 +202,7 @@ import {
194
202
  stripInternalDetailsFields,
195
203
  } from "./messages";
196
204
  import { type SessionManagerReadAccess, sessionManagerReadCapability } from "./session-manager-internal";
205
+ import { isStagedSessionPath, SESSION_STAGING_DIRNAME } from "./session-staging-paths";
197
206
  import type {
198
207
  ManagedSessionSecurityContext,
199
208
  SessionStorage,
@@ -281,6 +290,103 @@ interface PreparedNewSessionState extends PreparedNewSession {
281
290
  type ResidentTransitionFailurePolicy = "install-staged" | "memory-fallback" | "retain-and-throw" | "memory-only";
282
291
  type ResidentBlobMissingPolicy = "throw" | "placeholder";
283
292
 
293
+ const MAX_STAGED_ATTEMPT_ID_LENGTH = 128;
294
+
295
+ function assertSafeStagedAttemptId(attemptId: string): void {
296
+ if (!/^[A-Za-z0-9_-]{1,128}$/.test(attemptId) || attemptId.length > MAX_STAGED_ATTEMPT_ID_LENGTH)
297
+ throw new Error("Unsafe artifact attempt id");
298
+ }
299
+
300
+ const ATTEMPT_REMAP_STRUCTURAL_KEYS = new Set(["id", "parentId", "timestamp"]);
301
+ const ARTIFACT_REFERENCE_KEYS = new Set([
302
+ "artifactId",
303
+ "artifactIds",
304
+ "artifactRef",
305
+ "artifactRefs",
306
+ "agentId",
307
+ "agentIds",
308
+ "agentRef",
309
+ "agentRefs",
310
+ ]);
311
+ /**
312
+ * Trailing-selector grammar mirrored from `splitPathAndSel` / `splitInternalUrlSel` in
313
+ * `src/tools/path-utils.ts`. It is duplicated rather than imported because `path-utils` pulls in
314
+ * `internal-urls`, which reaches back into the session layer and would create an import cycle.
315
+ * The boundary red-team suite cross-checks these against the real parser so drift fails a test.
316
+ */
317
+ const SELECTOR_RANGE_RE = /^L?\d+(?:[-+]L?\d+|-)?(?:,L?\d+(?:[-+]L?\d+|-)?)*$/i;
318
+ const SELECTOR_TAIL_RE = /^(?:L?\d+(?:[-+]L?\d+|-)?(?:,L?\d+(?:[-+]L?\d+|-)?)*|raw|conflicts)$/i;
319
+
320
+ /**
321
+ * Decide whether a `:` directly after `<scheme>://<id>` terminates the id (so the id is a remappable
322
+ * reference and the tail is an opaque selector) or is part of an opaque authority (so the id must be
323
+ * left alone). `artifact://` splits unconditionally at the first colon; every other scheme requires a
324
+ * strict selector tail, so `agent://3:bogus` keeps `3:bogus` as the authority and must NOT be remapped.
325
+ */
326
+ function colonTerminatesUriId(scheme: string, tail: string): boolean {
327
+ if (scheme.toLowerCase() === "artifact") return true;
328
+ if (SELECTOR_TAIL_RE.test(tail)) return true;
329
+ const innerColon = tail.lastIndexOf(":");
330
+ if (innerColon <= 0) return false;
331
+ const head = tail.slice(0, innerColon);
332
+ const last = tail.slice(innerColon + 1);
333
+ const headIsRaw = /^raw$/i.test(head);
334
+ const lastIsRaw = /^raw$/i.test(last);
335
+ return (headIsRaw && SELECTOR_RANGE_RE.test(last)) || (SELECTOR_RANGE_RE.test(head) && lastIsRaw);
336
+ }
337
+
338
+ function remapArtifactReferenceString(value: string, idMap: ReadonlyMap<string, string>, exactId = false): string {
339
+ const exact = exactId ? idMap.get(value) : undefined;
340
+ if (exact !== undefined) return exact;
341
+ // Only re-key when the ENTIRE value is a single URI reference token. A string carrying prose or
342
+ // multiple tokens is opaque content we do not own, and rewriting inside it caused real corruption
343
+ // in earlier revisions. Everything after the id (selector, query, fragment) is likewise opaque and
344
+ // is preserved verbatim, so nested ids inside those payloads are never re-keyed.
345
+ if (/[\s"'<>]/.test(value)) return value;
346
+ const head = value.match(/^(artifact|agent):\/\/([0-9]+)/i);
347
+ if (!head) return value;
348
+ const protocol = head[1];
349
+ const mapped = idMap.get(head[2]);
350
+ if (mapped === undefined) return value;
351
+ const rest = value.slice(head[0].length);
352
+ if (rest !== "" && !/^[:/?#]/.test(rest)) return value;
353
+ if (rest.startsWith(":") && !colonTerminatesUriId(protocol, rest.slice(1))) return value;
354
+ return `${protocol}://${mapped}${rest}`;
355
+ }
356
+
357
+ function remapAttemptReferencesInEntries(entries: readonly FileEntry[], idMap: ReadonlyMap<string, string>): void {
358
+ if (idMap.size === 0) return;
359
+ const seen = new WeakSet<object>();
360
+ const visit = (value: unknown, key?: string): unknown => {
361
+ if (
362
+ typeof value === "number" &&
363
+ key !== undefined &&
364
+ ARTIFACT_REFERENCE_KEYS.has(key) &&
365
+ Number.isSafeInteger(value)
366
+ ) {
367
+ const mapped = idMap.get(String(value));
368
+ return mapped === undefined ? value : Number(mapped);
369
+ }
370
+ if (typeof value === "string") {
371
+ return remapArtifactReferenceString(value, idMap, key !== undefined && ARTIFACT_REFERENCE_KEYS.has(key));
372
+ }
373
+ if (!value || typeof value !== "object" || seen.has(value)) return value;
374
+ seen.add(value);
375
+ if (Array.isArray(value)) {
376
+ for (let index = 0; index < value.length; index++) value[index] = visit(value[index], key);
377
+ return value;
378
+ }
379
+ const record = value as Record<string, unknown>;
380
+ for (const [childKey, child] of Object.entries(record)) {
381
+ if (ATTEMPT_REMAP_STRUCTURAL_KEYS.has(childKey)) continue;
382
+ const next = visit(child, childKey);
383
+ if (next !== child) record[childKey] = next;
384
+ }
385
+ return value;
386
+ };
387
+ for (const entry of entries) visit(entry);
388
+ }
389
+
284
390
  type ResidentTransitionSource =
285
391
  | {
286
392
  mode: "materialize";
@@ -3395,6 +3501,7 @@ async function readTerminalBreadcrumb(cwd: string): Promise<string | null> {
3395
3501
  return null;
3396
3502
  }
3397
3503
 
3504
+ if (isStagedSessionPath(sessionFile)) return null;
3398
3505
  const inspected = inspectResumeSessionFile(sessionFile, new FileSessionStorage());
3399
3506
  if ("kind" in inspected) {
3400
3507
  if (inspected.reason !== "missing") return null;
@@ -3408,7 +3515,8 @@ async function readTerminalBreadcrumb(cwd: string): Promise<string | null> {
3408
3515
  const listing = listManagedCandidates(resolved.scope);
3409
3516
  if (listing.kind !== "complete") return null;
3410
3517
  const migrated = listing.owned.filter(
3411
- candidate => path.basename(candidate.path) === path.basename(sessionFile),
3518
+ candidate =>
3519
+ !isStagedSessionPath(candidate.path) && path.basename(candidate.path) === path.basename(sessionFile),
3412
3520
  );
3413
3521
  return migrated.length === 1 ? migrated[0]!.path : null;
3414
3522
  }
@@ -3423,9 +3531,14 @@ async function readTerminalBreadcrumb(cwd: string): Promise<string | null> {
3423
3531
  if (resolved.kind !== "resolved") return null;
3424
3532
  const listing = listManagedCandidates(resolved.scope);
3425
3533
  if (listing.kind !== "complete") return null;
3426
- const exact = listing.owned.find(candidate => path.resolve(candidate.path) === path.resolve(sessionFile));
3534
+ const exact = listing.owned.find(
3535
+ candidate =>
3536
+ !isStagedSessionPath(candidate.path) && path.resolve(candidate.path) === path.resolve(sessionFile),
3537
+ );
3427
3538
  if (exact) return exact.path;
3428
- const byIdentity = listing.owned.find(candidate => candidate.sessionId === header.id);
3539
+ const byIdentity = listing.owned.find(
3540
+ candidate => !isStagedSessionPath(candidate.path) && candidate.sessionId === header.id,
3541
+ );
3429
3542
  if (byIdentity) return byIdentity.path;
3430
3543
  return pathIsWithin(sessionsRoot, path.resolve(sessionFile)) ? null : path.resolve(sessionFile);
3431
3544
  } catch (err) {
@@ -6237,9 +6350,11 @@ const PROJECT_SESSION_SCAN_MAX_DIRECTORIES = 4096;
6237
6350
  const PROJECT_SESSION_SCAN_MAX_FILES = 1000;
6238
6351
 
6239
6352
  function isProjectSessionTranscriptPath(projectGjcDir: string, filePath: string): boolean {
6353
+ if (isStagedSessionPath(filePath)) return false;
6240
6354
  const relative = path.relative(projectGjcDir, filePath);
6241
6355
  if (relative.startsWith("..") || path.isAbsolute(relative)) return false;
6242
6356
  const segments = relative.split(path.sep);
6357
+ if (segments.includes(SESSION_STAGING_DIRNAME)) return false;
6243
6358
  if (segments.length === 1) return true;
6244
6359
  const parent = segments.at(-2);
6245
6360
  return parent === "agent-session" || segments.includes("sessions");
@@ -6250,7 +6365,7 @@ function isProjectSessionTranscriptPath(projectGjcDir: string, filePath: string)
6250
6365
  * Runtime token/audit JSONL files are excluded by requiring a known transcript
6251
6366
  * container (`agent-session` or `sessions`).
6252
6367
  */
6253
- function listProjectSessionTranscriptFiles(cwd: string): string[] {
6368
+ export function listProjectSessionTranscriptFiles(cwd: string): string[] {
6254
6369
  const projectGjcDir = path.join(path.resolve(cwd), ".gjc");
6255
6370
  let rootStat: fs.Stats;
6256
6371
  try {
@@ -6276,6 +6391,7 @@ function listProjectSessionTranscriptFiles(cwd: string): string[] {
6276
6391
  if (entry.isSymbolicLink()) continue;
6277
6392
  const entryPath = path.join(directory, entry.name);
6278
6393
  if (entry.isDirectory()) {
6394
+ if (entry.name === SESSION_STAGING_DIRNAME) continue;
6279
6395
  directories.push(entryPath);
6280
6396
  continue;
6281
6397
  }
@@ -7180,11 +7296,48 @@ export class SessionManager {
7180
7296
  /** Defense-in-depth (#4443): one-shot warn for adjacent private thinking blocks in persisted assistant transcripts. */
7181
7297
  #warnedAdjacentThinkingPersist = false;
7182
7298
  #closeRetryPending = false;
7299
+ /** Serializes model, SDK, and ACP cwd transitions; dispose joins this tail. */
7300
+ #cwdTransitionTail: Promise<void> = Promise.resolve();
7301
+ #cwdTransitionOwner: symbol | undefined;
7302
+ #cwdReadLeaseOwner = Symbol("cwd-read-lease-owner");
7303
+ #cwdGeneration = 0;
7304
+ /** Number of tool executions currently holding a shared read lease on `cwd`. */
7305
+ #cwdReaderCount = 0;
7306
+ /** Resolved when the last outstanding read lease is released. */
7307
+ #cwdReadersDrained: (() => void) | undefined;
7308
+ #cwdReadersIdle: Promise<void> = Promise.resolve();
7309
+ /** Set while a writer is queued or running so new readers do not starve it. */
7310
+ #cwdWriterPending = 0;
7183
7311
  /** Depth of the non-yielding same-session persistence fence (reentrancy counter). */
7184
7312
  #persistenceFenceDepth = 0;
7185
7313
  /** Publication fence counter carried by the mutable `.spill.commit` marker. */
7186
7314
  #commitGen = 0;
7187
7315
  /** Failed staged persistence retains its exact writer and temporary pathname for retryable cleanup. */
7316
+ /** Candidate-owned session publication state; set only by staged factories. */
7317
+ #stagedPublication:
7318
+ | {
7319
+ finalSessionFile: string;
7320
+ stagedSessionFile: string;
7321
+ finalDestination: SessionDestination;
7322
+ managedParentStore?: ManagedSessionDescendantStore;
7323
+ /** Subtree store bound to the staging directory; basename reads stay authority-safe. */
7324
+ managedStagingStore?: ManagedSessionDescendantStore;
7325
+ attemptId: string;
7326
+ committed: boolean;
7327
+ discarded: boolean;
7328
+ publishedFinalSnapshot?: ManagedFileSnapshot;
7329
+ publishedFinalBytes?: Buffer;
7330
+ deferArtifactFinalize?: boolean;
7331
+ /**
7332
+ * A publish that native code could neither prove nor disprove. The transcript may
7333
+ * already be visible at the destination, so no later cleanup may reclaim the
7334
+ * staging or artifacts it references.
7335
+ */
7336
+ preservedUnproven?: boolean;
7337
+ }
7338
+ | undefined;
7339
+ #stagedArtifactParent: ArtifactManager | null = null;
7340
+ #stagedCommitArtifactParent: ArtifactManager | null = null;
7188
7341
  #preparedNewSessionCleanupInProgress = false;
7189
7342
  /** Active cold-sidecar runtime (retirement + lazy resolution). Undefined when disabled. */
7190
7343
  #sidecarRuntime: SessionMemorySidecarRuntime | undefined = undefined;
@@ -9141,7 +9294,7 @@ export class SessionManager {
9141
9294
  this.#effectiveSessionMemoryMode(transcriptSize) === "enabled" &&
9142
9295
  this.#storage.existsSync(`${sidecarRoot}/.session-memory.spill.commit`);
9143
9296
  if (boundedTranscriptAdmitted && (await this.#tryInitSessionFileFromSidecar(resolvedSessionFile))) {
9144
- writeTerminalBreadcrumb(this.cwd, resolvedSessionFile);
9297
+ this.#writeTerminalBreadcrumb(resolvedSessionFile);
9145
9298
  revalidateStrictResume();
9146
9299
  return;
9147
9300
  }
@@ -9151,7 +9304,7 @@ export class SessionManager {
9151
9304
  this.#sessionMemoryMode = "shadow";
9152
9305
  this.#sessionMemoryAutoDisabledReason = "sidecar_reload_failures";
9153
9306
  }
9154
- writeTerminalBreadcrumb(this.cwd, resolvedSessionFile);
9307
+ this.#writeTerminalBreadcrumb(resolvedSessionFile);
9155
9308
  revalidateStrictResume();
9156
9309
  return;
9157
9310
  }
@@ -9167,7 +9320,7 @@ export class SessionManager {
9167
9320
  this.#applyFreshSessionMetadata(fresh);
9168
9321
  this.#commitResidentTextStoreTransition(prepared);
9169
9322
  this.#retireEphemeralArtifacts();
9170
- writeTerminalBreadcrumb(this.cwd, resolvedSessionFile);
9323
+ this.#writeTerminalBreadcrumb(resolvedSessionFile);
9171
9324
  await this.#rewriteFile();
9172
9325
  this.#flushed = true;
9173
9326
  this.#ensuredOnDisk = true;
@@ -9195,7 +9348,7 @@ export class SessionManager {
9195
9348
  this.#applyFreshSessionMetadata(fresh);
9196
9349
  this.#commitResidentTextStoreTransition(prepared);
9197
9350
  this.#retireEphemeralArtifacts();
9198
- writeTerminalBreadcrumb(this.cwd, resolvedSessionFile);
9351
+ this.#writeTerminalBreadcrumb(resolvedSessionFile);
9199
9352
  await this.#rewriteFile();
9200
9353
  this.#flushed = true;
9201
9354
  this.#ensuredOnDisk = true;
@@ -9228,7 +9381,7 @@ export class SessionManager {
9228
9381
  this.#titleSource = header?.titleSource;
9229
9382
  this.#needsFullRewriteOnNextPersist = migrationApplied;
9230
9383
  this.#commitResidentTextStoreTransition(prepared);
9231
- writeTerminalBreadcrumb(this.cwd, resolvedSessionFile);
9384
+ this.#writeTerminalBreadcrumb(resolvedSessionFile);
9232
9385
  this.#flushed = true;
9233
9386
  this.#ensuredOnDisk = true;
9234
9387
  this.#adoptManagedPersistIdentity(resolvedSessionFile);
@@ -9240,6 +9393,11 @@ export class SessionManager {
9240
9393
  }
9241
9394
  }
9242
9395
 
9396
+ #writeTerminalBreadcrumb(sessionFile: string): void {
9397
+ if (this.#stagedPublication && !this.#stagedPublication.committed) return;
9398
+ writeTerminalBreadcrumb(this.cwd, sessionFile);
9399
+ }
9400
+
9243
9401
  async #hydrateExistingSession(
9244
9402
  sessionFile: string,
9245
9403
  entries: FileEntry[],
@@ -10449,13 +10607,209 @@ export class SessionManager {
10449
10607
  }
10450
10608
  }
10451
10609
 
10610
+ async #assertCwdTargetIdentity(
10611
+ resolvedCwd: string,
10612
+ options: {
10613
+ expectedIdentity?: { dev: bigint; ino: bigint };
10614
+ targetHandle?: { stat: (opts: { bigint: true }) => Promise<fs.BigIntStats> };
10615
+ },
10616
+ ): Promise<void> {
10617
+ let opened: fs.BigIntStats | undefined;
10618
+ if (options.targetHandle) {
10619
+ opened = await options.targetHandle.stat({ bigint: true });
10620
+ if (!opened.isDirectory()) {
10621
+ throw new Error(
10622
+ `Refusing to move through a replaced path: ${resolvedCwd} is no longer the validated directory.`,
10623
+ );
10624
+ }
10625
+ }
10626
+ let observed: fs.BigIntStats;
10627
+ try {
10628
+ observed = await fs.promises.lstat(resolvedCwd, { bigint: true });
10629
+ } catch {
10630
+ throw new Error(`Directory identity unavailable at state-changing boundary: ${resolvedCwd}`);
10631
+ }
10632
+ if (observed.isSymbolicLink() || !observed.isDirectory()) {
10633
+ throw new Error(
10634
+ `Refusing to move through a replaced path: ${resolvedCwd} is no longer the validated directory.`,
10635
+ );
10636
+ }
10637
+ if (opened && (observed.dev !== opened.dev || observed.ino !== opened.ino)) {
10638
+ throw new Error(`Refusing to move: target identity changed at ${resolvedCwd}.`);
10639
+ }
10640
+ const expected = options.expectedIdentity;
10641
+ const pinned = opened ?? observed;
10642
+ if (expected && (pinned.dev !== expected.dev || pinned.ino !== expected.ino)) {
10643
+ throw new Error(`Refusing to move: target identity changed at ${resolvedCwd}.`);
10644
+ }
10645
+ }
10646
+
10647
+ /**
10648
+ * Serialize every cwd transition (model, TUI, SDK/ACP). Re-entry is allowed
10649
+ * only for the async context that already owns the lock — unrelated callers
10650
+ * queue on the tail instead of skipping it.
10651
+ */
10652
+ async runExclusiveCwdTransition<T>(fn: () => Promise<T>): Promise<T> {
10653
+ const owner = this.#cwdTransitionOwner;
10654
+ if (owner !== undefined && cwdTransitionAls.getStore() === owner) {
10655
+ return fn();
10656
+ }
10657
+ const previous = this.#cwdTransitionTail;
10658
+ const { promise, resolve } = Promise.withResolvers<void>();
10659
+ this.#cwdTransitionTail = previous.then(
10660
+ () => promise,
10661
+ () => promise,
10662
+ );
10663
+ // Announce the writer BEFORE awaiting the queue so readers arriving during
10664
+ // the wait queue behind it rather than starving it indefinitely.
10665
+ this.#cwdWriterPending += 1;
10666
+ const token = Symbol("cwd-transition");
10667
+ try {
10668
+ await previous.catch(() => {});
10669
+ // A reader that entered before this writer was announced still holds the
10670
+ // old cwd; the transition may not commit until every such lease is
10671
+ // released, otherwise an in-flight tool resolves paths across the move.
10672
+ while (this.#cwdReaderCount > 0) await this.#cwdReadersIdle;
10673
+ this.#cwdTransitionOwner = token;
10674
+ return await cwdTransitionAls.run(token, fn);
10675
+ } finally {
10676
+ if (this.#cwdTransitionOwner === token) this.#cwdTransitionOwner = undefined;
10677
+ this.#cwdWriterPending -= 1;
10678
+ resolve();
10679
+ }
10680
+ }
10681
+
10682
+ /**
10683
+ * Run `fn` under a shared read lease on `cwd`.
10684
+ *
10685
+ * Tools that resolve relative paths against the session cwd must hold this
10686
+ * lease across their WHOLE execution, not merely re-check a generation before
10687
+ * they start: the check-then-yield shape lets a move commit inside the tool's
10688
+ * first `await`, so a command admitted for root A would execute in root B.
10689
+ * Writers wait for outstanding leases to drain, so the cwd observed at lease
10690
+ * acquisition stays authoritative until the lease is released.
10691
+ */
10692
+ async runWithCwdReadLease<T>(fn: () => Promise<T>): Promise<T> {
10693
+ const activeReadLease = cwdReadLeaseAls.getStore();
10694
+ // Nested tool dispatch (for example eval -> tool bridge) inherits the
10695
+ // outer lease's async context. Re-entering that lease must not wait behind
10696
+ // a writer that is already queued: the writer is waiting for the outer
10697
+ // lease, and waiting here would deadlock the session permanently.
10698
+ if (activeReadLease?.active && activeReadLease.owner === this.#cwdReadLeaseOwner) return fn();
10699
+ const owner = this.#cwdTransitionOwner;
10700
+ // The writer's own async context already holds exclusive access; taking a
10701
+ // read lease there would wait on itself.
10702
+ if (owner !== undefined && cwdTransitionAls.getStore() === owner) return fn();
10703
+ while (this.#cwdWriterPending > 0) await this.#cwdTransitionTail.catch(() => {});
10704
+ if (this.#cwdReaderCount === 0) {
10705
+ const { promise, resolve } = Promise.withResolvers<void>();
10706
+ this.#cwdReadersIdle = promise;
10707
+ this.#cwdReadersDrained = resolve;
10708
+ }
10709
+ this.#cwdReaderCount += 1;
10710
+ const readLease: CwdReadLeaseContext = { active: true, owner: this.#cwdReadLeaseOwner };
10711
+ return cwdReadLeaseAls.run(readLease, async () => {
10712
+ try {
10713
+ return await fn();
10714
+ } finally {
10715
+ readLease.active = false;
10716
+ this.#cwdReaderCount -= 1;
10717
+ if (this.#cwdReaderCount === 0) {
10718
+ const drained = this.#cwdReadersDrained;
10719
+ this.#cwdReadersDrained = undefined;
10720
+ drained?.();
10721
+ }
10722
+ }
10723
+ });
10724
+ }
10725
+
10726
+ /** Wait for any in-flight exclusive cwd transition to settle. */
10727
+ async joinCwdTransition(): Promise<void> {
10728
+ await this.#cwdTransitionTail;
10729
+ }
10730
+ getCwdGeneration(): number {
10731
+ return this.#cwdGeneration;
10732
+ }
10733
+
10734
+ #ownsCwdTransition(): boolean {
10735
+ const owner = this.#cwdTransitionOwner;
10736
+ return owner !== undefined && cwdTransitionAls.getStore() === owner;
10737
+ }
10738
+ static async openNoFollowDirectory(dir: string): Promise<fs.promises.FileHandle> {
10739
+ return fs.promises.open(dir, CWD_NOFOLLOW_OPEN_FLAGS);
10740
+ }
10741
+
10742
+ /**
10743
+ * Owner of this process's cwd, or undefined when no session has claimed it.
10744
+ *
10745
+ * `process.chdir` and the caches keyed off it are process-global, so only one
10746
+ * session may drive them. Ownership is an explicit claim rather than an
10747
+ * inference from `process.cwd() === session.cwd`: two sessions launched at the
10748
+ * same root both satisfy that comparison, and letting either one act on it
10749
+ * lets a move in one session chdir the process under its sibling.
10750
+ */
10751
+ static #processCwdOwner: WeakRef<SessionManager> | undefined;
10752
+
10753
+ /**
10754
+ * Claim process-cwd authority for `manager` when it is unowned or the prior
10755
+ * owner has been collected. Returns whether `manager` holds the claim.
10756
+ */
10757
+ static claimProcessCwdOwnership(manager: SessionManager): boolean {
10758
+ const current = SessionManager.#processCwdOwner?.deref();
10759
+ if (current === manager) return true;
10760
+ if (current !== undefined) return false;
10761
+ SessionManager.#processCwdOwner = new WeakRef(manager);
10762
+ return true;
10763
+ }
10764
+
10765
+ static isProcessCwdOwner(manager: SessionManager): boolean {
10766
+ return SessionManager.#processCwdOwner?.deref() === manager;
10767
+ }
10768
+
10769
+ static releaseProcessCwdOwnership(manager: SessionManager): void {
10770
+ if (SessionManager.#processCwdOwner?.deref() === manager) SessionManager.#processCwdOwner = undefined;
10771
+ }
10772
+
10773
+ /**
10774
+ * Verify that `process.cwd()` is the directory pinned by `expectedIdentity`.
10775
+ *
10776
+ * `process.chdir` resolves a NAME, so a path replaced after the last
10777
+ * name-based comparison lands the process outside the validated directory —
10778
+ * the exact confinement `move_session` exists to enforce. Node exposes no
10779
+ * `fchdir`, so the handle cannot be the chdir authority directly; comparing
10780
+ * the resulting cwd's identity to the pinned handle closes the same gap.
10781
+ */
10782
+ static async assertProcessCwdIdentity(expectedIdentity: { dev: bigint; ino: bigint }): Promise<void> {
10783
+ const observed = await fs.promises.stat(process.cwd(), { bigint: true });
10784
+ if (observed.dev !== expectedIdentity.dev || observed.ino !== expectedIdentity.ino) {
10785
+ throw new Error(
10786
+ `Refusing to rescope: process cwd ${process.cwd()} is not the validated target directory (identity changed).`,
10787
+ );
10788
+ }
10789
+ }
10790
+
10452
10791
  /**
10453
10792
  * Move the session to a new working directory.
10454
10793
  * Moves session files and artifacts on disk, updates all internal references,
10455
10794
  * and rewrites the session header with the new cwd.
10795
+ *
10796
+ * All callers (model `move_session`, TUI `/move`, SDK/ACP `session.cwd.move`)
10797
+ * share this exclusive transition so concurrent moves cannot interleave.
10456
10798
  */
10457
- async moveTo(newCwd: string): Promise<void> {
10799
+ async moveTo(
10800
+ newCwd: string,
10801
+ options?: {
10802
+ expectedIdentity?: { dev: bigint; ino: bigint };
10803
+ targetHandle?: { stat: (opts: { bigint: true }) => Promise<fs.BigIntStats> };
10804
+ },
10805
+ ): Promise<void> {
10806
+ if (!this.#ownsCwdTransition()) {
10807
+ return this.runExclusiveCwdTransition(() => this.moveTo(newCwd, options));
10808
+ }
10458
10809
  const resolvedCwd = path.resolve(newCwd);
10810
+ if (options?.expectedIdentity || options?.targetHandle) {
10811
+ await this.#assertCwdTargetIdentity(resolvedCwd, options);
10812
+ }
10459
10813
  if (resolvedCwd === this.cwd) return;
10460
10814
  const previousCwd = this.cwd;
10461
10815
  const previousSessionDir = this.sessionDir;
@@ -10700,6 +11054,10 @@ export class SessionManager {
10700
11054
 
10701
11055
  // Update cwd and sessionDir after physical publication succeeds. Metadata failures restore the source
10702
11056
  // authority but deliberately retain any destination publication evidence rather than deleting it.
11057
+ if (options?.expectedIdentity || options?.targetHandle) {
11058
+ await this.#assertCwdTargetIdentity(resolvedCwd, options);
11059
+ }
11060
+ this.#cwdGeneration += 1;
10703
11061
  this.cwd = resolvedCwd;
10704
11062
  this.sessionDir = newSessionDir;
10705
11063
  this.destination = nextDestination;
@@ -15216,6 +15574,8 @@ export class SessionManager {
15216
15574
 
15217
15575
  /** Close the persistent writer after flushing all pending data. */
15218
15576
  async close(): Promise<void> {
15577
+ await this.joinCwdTransition();
15578
+ SessionManager.releaseProcessCwdOwnership(this);
15219
15579
  // Drain any uncommitted prepared successors before releasing resources so
15220
15580
  // dispose/shutdown retains exact cleanup authority (#3138).
15221
15581
  try {
@@ -15814,8 +16174,9 @@ export class SessionManager {
15814
16174
  * Adopt an externally-owned ArtifactManager. Used by subagents to share
15815
16175
  * the parent session's artifact directory and ID counter.
15816
16176
  */
15817
- adoptArtifactManager(manager: ArtifactManager): void {
16177
+ adoptArtifactManager(manager: ArtifactManager, parent?: ArtifactManager): void {
15818
16178
  this.#adoptedArtifactManager = manager;
16179
+ if (parent) this.#stagedArtifactParent = parent;
15819
16180
  }
15820
16181
 
15821
16182
  /** Release only the matching externally adopted manager. */
@@ -17019,6 +17380,15 @@ export class SessionManager {
17019
17380
  }
17020
17381
  }
17021
17382
 
17383
+ /** Remap artifact references in an unpublished candidate before its publication fence. */
17384
+ async remapStagedArtifactReferences(idMap: ReadonlyMap<string, string>): Promise<void> {
17385
+ const staged = this.#stagedPublication;
17386
+ if (!staged || staged.committed || staged.discarded) throw new Error("Staged session is unavailable");
17387
+ remapAttemptReferencesInEntries(this.#fileEntries, idMap);
17388
+ this.#needsFullRewriteOnNextPersist = true;
17389
+ await this.#rewriteFileContents();
17390
+ }
17391
+
17022
17392
  /**
17023
17393
  * Append a custom message entry (for extensions) that participates in LLM context.
17024
17394
  * @param customType Hook identifier for filtering on reload
@@ -17878,7 +18248,7 @@ export class SessionManager {
17878
18248
 
17879
18249
  async #sanitizeLoadedOpenAIResponsesReplayMetadataAndPersist(): Promise<boolean> {
17880
18250
  const patches = this.#sanitizeLoadedOpenAIResponsesReplayMetadata();
17881
- await this.#persistPatches(patches);
18251
+ if (!this.isManagedDestination()) await this.#persistPatches(patches);
17882
18252
  return patches.length > 0;
17883
18253
  }
17884
18254
 
@@ -18951,6 +19321,383 @@ export class SessionManager {
18951
19321
  * @param path Path to session file
18952
19322
  * @param sessionDir Optional session directory for /new or /branch. If omitted, derives from file's parent.
18953
19323
  */
19324
+ /** Open an unpublished candidate transcript below the reserved staging directory. */
19325
+ static async openStaged(
19326
+ finalSessionFile: string,
19327
+ storage: SessionStorage = new FileSessionStorage(),
19328
+ attemptId: string = crypto.randomUUID(),
19329
+ ): Promise<SessionManager> {
19330
+ assertSafeStagedAttemptId(attemptId);
19331
+
19332
+ if (isStagedSessionPath(finalSessionFile)) throw new Error("Final session path cannot be staged");
19333
+ const finalPath =
19334
+ storage instanceof FileSessionStorage ? canonicalizeTrustedPath(finalSessionFile) : finalSessionFile;
19335
+ const finalDestination = explicitDestination(path.dirname(finalPath));
19336
+ const stagingDir = path.join(path.dirname(finalPath), SESSION_STAGING_DIRNAME);
19337
+ await fs.promises.mkdir(stagingDir, { recursive: true, mode: 0o700 });
19338
+ const stagedSessionFile = path.join(stagingDir, `${attemptId}.jsonl`);
19339
+ if (path.resolve(stagedSessionFile) === path.resolve(finalPath))
19340
+ throw new Error("Staged session path collides with final transcript");
19341
+
19342
+ const manager = new SessionManager(getProjectDir(), stagingDir, true, storage, finalDestination);
19343
+ manager.#stagedPublication = {
19344
+ finalSessionFile: finalPath,
19345
+ stagedSessionFile,
19346
+ finalDestination,
19347
+ attemptId,
19348
+ committed: false,
19349
+ discarded: false,
19350
+ };
19351
+ if (fs.existsSync(stagedSessionFile)) throw new Error("Staged session attempt already exists");
19352
+ try {
19353
+ await manager.#initSessionFile(stagedSessionFile, true);
19354
+ const parentArtifacts = new ArtifactManager(finalPath.endsWith(".jsonl") ? finalPath.slice(0, -6) : finalPath);
19355
+ manager.adoptArtifactManager(parentArtifacts.createAttemptStaging(attemptId), parentArtifacts);
19356
+ return manager;
19357
+ } catch (error) {
19358
+ try {
19359
+ await manager.discardStaged();
19360
+ } catch (cleanupError) {
19361
+ throw new AggregateError(
19362
+ [toError(error), toError(cleanupError)],
19363
+ "Staged session open and cleanup both failed.",
19364
+ );
19365
+ }
19366
+ throw error;
19367
+ }
19368
+ }
19369
+
19370
+ /** Managed-authority variant of {@link openStaged}. */
19371
+ static async stagedNestedManaged(
19372
+ finalSessionFile: string,
19373
+ destination: SessionDestination,
19374
+ store: ManagedSessionDescendantStore,
19375
+ storage: SessionStorage = new FileSessionStorage(),
19376
+ attemptId: string = crypto.randomUUID(),
19377
+ ): Promise<SessionManager> {
19378
+ assertSafeStagedAttemptId(attemptId);
19379
+
19380
+ if (destination.kind !== "managed" || !trustedSessionDestinations.has(destination))
19381
+ throw new Error("Nested managed session authority is unavailable");
19382
+ if (isStagedSessionPath(finalSessionFile)) throw new Error("Final session path cannot be staged");
19383
+ store.assertBound();
19384
+ const finalPath = path.resolve(finalSessionFile);
19385
+ if (path.dirname(finalPath) !== path.resolve(destination.directory))
19386
+ throw new Error("Nested managed session escaped retained authority");
19387
+ const stagingStore = store.deriveSubtree(SESSION_STAGING_DIRNAME);
19388
+ const stagedDir = stagingStore.dir;
19389
+ const stagedSessionFile = path.join(stagedDir, `${attemptId}.jsonl`);
19390
+ if (path.resolve(stagedSessionFile) === path.resolve(finalPath))
19391
+ throw new Error("Staged session path collides with final transcript");
19392
+ const stagedDestination = SessionManager.nestedManagedDestination(stagingStore, stagedDir);
19393
+ const manager = new SessionManager(getProjectDir(), stagedDir, true, storage, stagedDestination);
19394
+ manager.#stagedPublication = {
19395
+ finalSessionFile: finalPath,
19396
+ stagedSessionFile,
19397
+ finalDestination: destination,
19398
+ managedParentStore: store,
19399
+ managedStagingStore: stagingStore,
19400
+ attemptId,
19401
+ committed: false,
19402
+ discarded: false,
19403
+ };
19404
+ if (fs.existsSync(stagedSessionFile)) throw new Error("Staged session attempt already exists");
19405
+ try {
19406
+ await manager.#initSessionFile(stagedSessionFile, true);
19407
+ const parentArtifacts = new ArtifactManager(store);
19408
+ manager.adoptArtifactManager(parentArtifacts.createAttemptStaging(attemptId), parentArtifacts);
19409
+ store.assertBound();
19410
+ return manager;
19411
+ } catch (error) {
19412
+ try {
19413
+ await manager.discardStaged();
19414
+ } catch (cleanupError) {
19415
+ throw new AggregateError(
19416
+ [toError(error), toError(cleanupError)],
19417
+ "Staged session open and cleanup both failed.",
19418
+ );
19419
+ }
19420
+ throw error;
19421
+ }
19422
+ }
19423
+ /** Publish the candidate-owned staged transcript and artifacts at the real accept fence. */
19424
+ static async openStagedNestedManaged(
19425
+ finalSessionFile: string,
19426
+ destination: SessionDestination,
19427
+ store: ManagedSessionDescendantStore,
19428
+ storage: SessionStorage = new FileSessionStorage(),
19429
+ attemptId: string = crypto.randomUUID(),
19430
+ ): Promise<SessionManager> {
19431
+ return SessionManager.stagedNestedManaged(finalSessionFile, destination, store, storage, attemptId);
19432
+ }
19433
+
19434
+ async commitStaged(options?: { deferArtifactFinalize?: boolean }): Promise<void> {
19435
+ const staged = this.#stagedPublication;
19436
+ if (!staged || staged.discarded) throw new Error("Staged session is unavailable");
19437
+ if (staged.committed) return;
19438
+ staged.deferArtifactFinalize = options?.deferArtifactFinalize === true;
19439
+ await this.flush();
19440
+ await this.#closePersistWriter();
19441
+ const stagedManager = this.#adoptedArtifactManager ?? this.#artifactManager;
19442
+ // Lifecycle invariant: while a staged publication is uncommitted, the session must
19443
+ // carry exactly the attempt-rooted staging manager it opened with. openStaged,
19444
+ // stagedNestedManaged, and openStagedSession all pre-adopt an attempt-rooted
19445
+ // manager, so an adopted manager that is absent or foreign means a second root
19446
+ // replaced the first — orphaning the original from commit/discard cleanup.
19447
+ if (this.#adoptedArtifactManager?.getAttemptId() !== staged.attemptId)
19448
+ throw new Error("Staged session artifact root does not match the staged attempt.");
19449
+ const parentArtifacts =
19450
+ this.#stagedArtifactParent ??
19451
+ new ArtifactManager(
19452
+ staged.finalSessionFile.endsWith(".jsonl") ? staged.finalSessionFile.slice(0, -6) : staged.finalSessionFile,
19453
+ );
19454
+ this.#stagedCommitArtifactParent = parentArtifacts;
19455
+ let published = false;
19456
+ try {
19457
+ if (stagedManager?.getAttemptId() === staged.attemptId) {
19458
+ const stagedArtifactFiles = await stagedManager.listFiles();
19459
+ if (stagedArtifactFiles.length > 0 || stagedManager.getAllocatedIds().length > 0) {
19460
+ await parentArtifacts.commitAttemptStaging(stagedManager, staged.attemptId, {
19461
+ beforePublish: idMap => this.remapStagedArtifactReferences(idMap),
19462
+ });
19463
+ } else await stagedManager.discardAttemptStaging();
19464
+ }
19465
+ if (staged.managedParentStore && staged.managedStagingStore) {
19466
+ const stagedName = path.basename(staged.stagedSessionFile);
19467
+ const relative = path.posix.join(SESSION_STAGING_DIRNAME, stagedName);
19468
+ const expected = staged.managedStagingStore.readExpected(stagedName);
19469
+ if (!expected) throw new Error("staged_session_missing");
19470
+ staged.managedParentStore.moveFileNoReplace(relative, path.basename(staged.finalSessionFile), expected, {
19471
+ sourceStore: staged.managedStagingStore,
19472
+ sourceStoreRelativePath: stagedName,
19473
+ });
19474
+ } else {
19475
+ const outcome = classifyNativePublishOutcome(
19476
+ nativeSessionManager().renameNoReplacePath(staged.stagedSessionFile, staged.finalSessionFile),
19477
+ );
19478
+ if (!outcome.ok) throw new Error(outcome.code ?? "staged_session_publish_failed");
19479
+ }
19480
+ published = true;
19481
+ if (staged.managedParentStore) {
19482
+ staged.publishedFinalSnapshot =
19483
+ staged.managedParentStore.readExpected(path.basename(staged.finalSessionFile)) ?? undefined;
19484
+ if (!staged.publishedFinalSnapshot) throw new Error("staged_session_publish_missing");
19485
+ } else staged.publishedFinalBytes = await fs.promises.readFile(staged.finalSessionFile);
19486
+
19487
+ const finalStat = fs.lstatSync(staged.finalSessionFile, { bigint: true });
19488
+ if (!finalStat.isFile() || finalStat.isSymbolicLink()) throw new Error("staged_session_identity_changed");
19489
+ this.sessionDir = path.dirname(staged.finalSessionFile);
19490
+ this.destination = staged.finalDestination;
19491
+ this.#managedTranscriptStoreCache = staged.managedParentStore
19492
+ ? { directory: path.dirname(staged.finalSessionFile), store: staged.managedParentStore }
19493
+ : null;
19494
+ this.#sessionFile = staged.finalSessionFile;
19495
+ this.#artifactManager = parentArtifacts;
19496
+ this.#artifactManagerSessionFile = staged.finalSessionFile;
19497
+ this.#adoptedArtifactManager = parentArtifacts;
19498
+ staged.committed = true;
19499
+ if (!staged.deferArtifactFinalize) {
19500
+ writeTerminalBreadcrumb(this.cwd, staged.finalSessionFile);
19501
+ parentArtifacts.finalizeLastAttemptCommit(staged.attemptId);
19502
+ }
19503
+ } catch (error) {
19504
+ const cleanupErrors: Error[] = [];
19505
+ // A managed move can commit and still report failure when native code cannot
19506
+ // prove durability or terminal identity. Treating that as unpublished would
19507
+ // roll the artifacts back underneath a transcript that is already visible at
19508
+ // the destination, so probe the destination first and preserve every owned
19509
+ // artifact when the mutation may have landed.
19510
+ if (!published && staged.managedParentStore && !mayCleanManagedTreeStaging(error)) {
19511
+ const probeErrors: Error[] = [];
19512
+ let destination: ManagedFileSnapshot | null = null;
19513
+ try {
19514
+ destination = staged.managedParentStore.readExpected(path.basename(staged.finalSessionFile));
19515
+ } catch (probeError) {
19516
+ probeErrors.push(toError(probeError));
19517
+ }
19518
+ // Absence must be proven; an unreadable destination stays fail-closed.
19519
+ if (destination || probeErrors.length > 0) {
19520
+ staged.publishedFinalSnapshot = destination ?? undefined;
19521
+ // Latch the uncertainty so no later compensation reclaims what a possibly
19522
+ // published transcript references.
19523
+ staged.preservedUnproven = true;
19524
+ throw new AggregateError(
19525
+ [toError(error), ...probeErrors],
19526
+ "Staged publication may have committed without proof; artifacts and staging were preserved for recovery.",
19527
+ );
19528
+ }
19529
+ }
19530
+ if (published) {
19531
+ try {
19532
+ if (staged.managedParentStore) {
19533
+ const finalSnapshot = staged.managedParentStore.readExpected(path.basename(staged.finalSessionFile));
19534
+ if (finalSnapshot)
19535
+ staged.managedParentStore.removeExpected(path.basename(staged.finalSessionFile), finalSnapshot);
19536
+ } else await fs.promises.rm(staged.finalSessionFile, { force: true });
19537
+ } catch (cleanupError) {
19538
+ cleanupErrors.push(toError(cleanupError));
19539
+ }
19540
+ }
19541
+ try {
19542
+ await parentArtifacts.rollbackLastAttemptCommit(staged.attemptId);
19543
+ } catch (cleanupError) {
19544
+ cleanupErrors.push(toError(cleanupError));
19545
+ }
19546
+ try {
19547
+ await this.discardStaged();
19548
+ } catch (cleanupError) {
19549
+ cleanupErrors.push(toError(cleanupError));
19550
+ }
19551
+ if (cleanupErrors.length > 0)
19552
+ throw new AggregateError([toError(error), ...cleanupErrors], "Staged publication and cleanup both failed.");
19553
+ throw error;
19554
+ }
19555
+ }
19556
+
19557
+ /** Finalize a staged publication whose post-fence publisher completed successfully. */
19558
+ finalizeStagedCommit(): void {
19559
+ const staged = this.#stagedPublication;
19560
+ if (!staged?.committed || !staged.deferArtifactFinalize) return;
19561
+ this.#stagedCommitArtifactParent?.finalizeLastAttemptCommit(staged.attemptId);
19562
+ writeTerminalBreadcrumb(this.cwd, staged.finalSessionFile);
19563
+ staged.deferArtifactFinalize = false;
19564
+ }
19565
+
19566
+ /** Roll back a staged publication when post-fence visibility setup fails. */
19567
+ async rollbackCommittedStaged(): Promise<void> {
19568
+ const staged = this.#stagedPublication;
19569
+ if (!staged?.committed) return;
19570
+ if (staged.managedParentStore) {
19571
+ const current = staged.managedParentStore.readExpected(path.basename(staged.finalSessionFile));
19572
+ if (
19573
+ current &&
19574
+ staged.publishedFinalSnapshot &&
19575
+ current.identity.dev === staged.publishedFinalSnapshot.identity.dev &&
19576
+ current.identity.ino === staged.publishedFinalSnapshot.identity.ino
19577
+ ) {
19578
+ if (this.#stagedCommitArtifactParent) {
19579
+ const removed = await this.#stagedCommitArtifactParent.removeNamedBestEffort(
19580
+ path.basename(staged.finalSessionFile),
19581
+ );
19582
+ if (!removed) throw new Error("staged_final_cleanup_failed");
19583
+ } else staged.managedParentStore.removeExpected(path.basename(staged.finalSessionFile), current);
19584
+ }
19585
+ } else if (staged.publishedFinalBytes) {
19586
+ const current = await fs.promises.readFile(staged.finalSessionFile).catch(() => undefined);
19587
+ if (current?.equals(staged.publishedFinalBytes))
19588
+ await fs.promises.rm(staged.finalSessionFile, { force: true });
19589
+ }
19590
+ await this.#stagedCommitArtifactParent?.rollbackLastAttemptCommit(staged.attemptId);
19591
+ staged.committed = false;
19592
+ staged.discarded = true;
19593
+ staged.deferArtifactFinalize = false;
19594
+ }
19595
+
19596
+ /** Refresh the owned final snapshot after post-fence session metadata is appended. */
19597
+ async refreshStagedCommitSnapshot(): Promise<void> {
19598
+ const staged = this.#stagedPublication;
19599
+ if (!staged?.committed) return;
19600
+ if (staged.managedParentStore) {
19601
+ staged.publishedFinalSnapshot =
19602
+ staged.managedParentStore.readExpected(path.basename(staged.finalSessionFile)) ?? undefined;
19603
+ if (!staged.publishedFinalSnapshot) throw new Error("staged_session_publish_missing");
19604
+ } else staged.publishedFinalBytes = await fs.promises.readFile(staged.finalSessionFile);
19605
+ }
19606
+
19607
+ /** Idempotently remove an unpublished staged transcript and its owned artifacts. */
19608
+ async discardStaged(): Promise<void> {
19609
+ const staged = this.#stagedPublication;
19610
+ if (!staged || staged.committed || staged.discarded) return;
19611
+ // An unproven publish may already be visible at the destination; reclaiming its
19612
+ // staging or artifacts would strand that transcript with dangling references.
19613
+ if (staged.preservedUnproven) return;
19614
+ const cleanupErrors: Error[] = [];
19615
+ // Same strict single-root invariant as commitStaged: an absent or foreign adopted
19616
+ // manager means the publication's own attempt root was replaced or released.
19617
+ if (this.#adoptedArtifactManager?.getAttemptId() !== staged.attemptId) {
19618
+ cleanupErrors.push(new Error("Staged session artifact root does not match the staged attempt."));
19619
+ }
19620
+ const captureCleanupError = (error: unknown): void => {
19621
+ const normalized = toError(error);
19622
+ if (normalized.message !== "not_found" && !isAuthorizedPendingCleanup(normalized))
19623
+ cleanupErrors.push(normalized);
19624
+ };
19625
+ try {
19626
+ await this.#closePersistWriter();
19627
+ } catch (error) {
19628
+ captureCleanupError(error);
19629
+ }
19630
+ let managedStagingBefore: ReturnType<ManagedSessionDescendantStore["captureTree"]> | undefined;
19631
+ if (staged.managedParentStore) {
19632
+ try {
19633
+ managedStagingBefore = staged.managedParentStore.captureTree(SESSION_STAGING_DIRNAME);
19634
+ } catch (error) {
19635
+ captureCleanupError(error);
19636
+ }
19637
+ }
19638
+
19639
+ const stagedManager = this.#adoptedArtifactManager ?? this.#artifactManager;
19640
+ if (stagedManager?.getAttemptId() === staged.attemptId) {
19641
+ try {
19642
+ await stagedManager.discardAttemptStaging();
19643
+ } catch (error) {
19644
+ captureCleanupError(error);
19645
+ }
19646
+ }
19647
+ if (staged.managedParentStore) {
19648
+ const stagedName = path.basename(staged.stagedSessionFile);
19649
+ try {
19650
+ const stagingStore = staged.managedStagingStore;
19651
+ if (stagingStore) {
19652
+ const expected = stagingStore.readExpected(stagedName);
19653
+ if (expected) stagingStore.removeExpected(stagedName, expected);
19654
+ }
19655
+ } catch (error) {
19656
+ captureCleanupError(error);
19657
+ }
19658
+ if (managedStagingBefore) {
19659
+ try {
19660
+ const after = staged.managedParentStore.captureTree(SESSION_STAGING_DIRNAME);
19661
+ const beforePaths = new Set(managedStagingBefore.entries.map(entry => entry.relativePath));
19662
+ for (const entry of after.entries) {
19663
+ if (
19664
+ entry.relativePath.length === 0 ||
19665
+ beforePaths.has(entry.relativePath) ||
19666
+ (!/^\.gjc-/u.test(path.posix.basename(entry.relativePath)) &&
19667
+ !/\.removing$/u.test(path.posix.basename(entry.relativePath)))
19668
+ )
19669
+ continue;
19670
+ try {
19671
+ await fs.promises.rm(
19672
+ path.join(staged.managedParentStore.dir, SESSION_STAGING_DIRNAME, entry.relativePath),
19673
+ { recursive: entry.kind === "directory", force: true },
19674
+ );
19675
+ } catch (error) {
19676
+ captureCleanupError(error);
19677
+ }
19678
+ }
19679
+ } catch (error) {
19680
+ captureCleanupError(error);
19681
+ }
19682
+ }
19683
+ } else {
19684
+ try {
19685
+ await fs.promises.rm(staged.stagedSessionFile, { force: true });
19686
+ } catch (error) {
19687
+ captureCleanupError(error);
19688
+ }
19689
+ }
19690
+ if (cleanupErrors.length > 0) throw new AggregateError(cleanupErrors, "Staged session cleanup failed.");
19691
+ staged.discarded = true;
19692
+ }
19693
+ async commitStagedNestedManaged(): Promise<void> {
19694
+ return this.commitStaged();
19695
+ }
19696
+
19697
+ async discardStagedNestedManaged(): Promise<void> {
19698
+ return this.discardStaged();
19699
+ }
19700
+
18954
19701
  static async open(
18955
19702
  filePath: string,
18956
19703
  destinationInput?: SessionDestinationInput,
@@ -18958,6 +19705,7 @@ export class SessionManager {
18958
19705
  migrationPolicy: SessionDirectoryMigrationPolicy = "copy-retain",
18959
19706
  sessionMemoryMode: SessionMemoryMode = "shadow",
18960
19707
  ): Promise<SessionManager> {
19708
+ if (isStagedSessionPath(filePath)) throw new Error("Staged session paths are not resumable");
18961
19709
  const destination =
18962
19710
  destinationInput === undefined
18963
19711
  ? explicitDestination(path.dirname(filePath))
@@ -19446,7 +20194,7 @@ export class SessionManager {
19446
20194
  const listing = listManagedCandidates(resolved.scope);
19447
20195
  if (listing.kind === "error") return [];
19448
20196
  const managed = await collectSessionsFromFiles(
19449
- listing.owned.map(candidate => candidate.path),
20197
+ listing.owned.filter(candidate => !isStagedSessionPath(candidate.path)).map(candidate => candidate.path),
19450
20198
  storage,
19451
20199
  );
19452
20200
  return mergeSessionInventories(managed, await collectProjectSessions(cwd, storage));
@@ -19464,7 +20212,10 @@ export class SessionManager {
19464
20212
  ): Promise<SessionInfo[]> {
19465
20213
  if (!sessionDir) return await SessionManager.listManagedForResumePickerReadOnly(cwd, undefined, storage);
19466
20214
  try {
19467
- return await collectSessionsFromFiles(storage.listFilesSync(sessionDir, "*.jsonl"), storage);
20215
+ return await collectSessionsFromFiles(
20216
+ storage.listFilesSync(sessionDir, "*.jsonl").filter(file => !isStagedSessionPath(file)),
20217
+ storage,
20218
+ );
19468
20219
  } catch {
19469
20220
  return [];
19470
20221
  }
@@ -20184,7 +20935,13 @@ export class SessionManager {
20184
20935
  await manager.close();
20185
20936
  return ownershipInspection;
20186
20937
  }
20187
- manager.#sanitizeLoadedOpenAIResponsesReplayMetadata();
20938
+ try {
20939
+ await manager.#sanitizeLoadedOpenAIResponsesReplayMetadataAndPersist();
20940
+ writeTerminalBreadcrumb(manager.cwd, sessionPath);
20941
+ } catch (error) {
20942
+ await manager.close();
20943
+ throw error;
20944
+ }
20188
20945
  return { kind: "opened", manager };
20189
20946
  }
20190
20947
 
@@ -20368,7 +21125,8 @@ export class SessionManager {
20368
21125
  logger.warn("Ignored invalid managed session candidates during global listing", {
20369
21126
  count: listing.invalid.length,
20370
21127
  });
20371
- for (const candidate of listing.owned) logicalFiles.add(candidate.path);
21128
+ for (const candidate of listing.owned)
21129
+ if (!isStagedSessionPath(candidate.path)) logicalFiles.add(candidate.path);
20372
21130
  }
20373
21131
  return await collectSessionsFromFiles([...logicalFiles], storage);
20374
21132
  } catch {
@@ -20427,6 +21185,7 @@ export class SessionManager {
20427
21185
  const failures: StrictInventoryFailure[] = [];
20428
21186
  const candidates: StrictInventoryCandidate[] = [];
20429
21187
  for (const managedCandidate of listing.owned) {
21188
+ if (isStagedSessionPath(managedCandidate.path)) continue;
20430
21189
  const candidate = inventoryReadCandidate(
20431
21190
  storage,
20432
21191
  managedCandidate.path,