@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
@@ -0,0 +1,594 @@
1
+ /**
2
+ * Fail-atomic user-file writes for the write/edit/LSP writethrough path.
3
+ *
4
+ * The guarantee is visibility, not crash durability: a failed write never
5
+ * publishes partial or truncated bytes, and the destination is left byte-
6
+ * identical. It is deliberately NOT crash-atomic -- the parent directory is
7
+ * never fsynced, so a rename can be lost across a system crash.
8
+ *
9
+ * Publication is last-writer-wins, not conditional. Identity is revalidated
10
+ * immediately before the rename, which rejects a destination that was replaced
11
+ * or retargeted while staging, but `rename(2)` commits against the pathname:
12
+ * a writer that publishes a successor inside the window between that check and
13
+ * the rename is overwritten. Closing that window needs an OS conditional-
14
+ * replace primitive; the exchange-based one available here was removed because
15
+ * it validated only after committing and leaked protocol debris into user
16
+ * directories on every successful write. `write` is a last-writer-wins tool by
17
+ * contract, so this is the documented behavior rather than a silent race.
18
+ *
19
+ * `Bun.write` truncates the destination then copies bytes. A permission or IO
20
+ * failure after that truncate leaves a 0-byte target even though the tool
21
+ * reported an error. Stage to a sibling temp, then rename over the destination
22
+ * so a failed attempt never publishes a truncated file. Directory fsync is
23
+ * intentionally omitted: Windows reports `EPERM` for it (#4457) and user-file
24
+ * publication does not need that durability barrier.
25
+ *
26
+ * Destination symlinks are followed: the referent is replaced, the link stays.
27
+ * The referent is re-resolved immediately before publication so a retargeted
28
+ * link cannot silently repoint the write, and when the lexical destination
29
+ * sits inside a session-scoped `gjc-local` root the resolved referent and its
30
+ * parent must remain inside that root (a link there must not redirect the
31
+ * write out of the trust boundary).
32
+ *
33
+ * Staging uses exclusive create (`wx`) so a colliding leftover temp is never
34
+ * truncated or unlinked; only the temp this call created is cleaned on failure.
35
+ * Existing-file mode and ownership are re-applied after staging so a process
36
+ * umask or replacement inode never changes the target's identity. Hard-linked
37
+ * targets are rejected because replacement would split their link group, and
38
+ * target identity is revalidated before the final same-directory rename. The
39
+ * staged bytes are synced before publication; directory fsync is intentionally not
40
+ * promised because Windows reports EPERM for it (#4457).
41
+ */
42
+
43
+ import * as fs from "node:fs/promises";
44
+ import * as os from "node:os";
45
+ import * as path from "node:path";
46
+ import { hasFsCode, isEacces, isEisdir, isEnoent, isFsError } from "@gajae-code/utils";
47
+
48
+ const TEMP_CREATE_ATTEMPTS = 8;
49
+ const DEFAULT_FILE_MODE = 0o666;
50
+ const WINDOWS_RENAME_BACKOFF_MS = [10, 25, 50, 100, 200] as const;
51
+ const WINDOWS_SHARING_VIOLATION_CODES = new Set(["EPERM", "EACCES", "EBUSY"]);
52
+
53
+ export type FileWritePublicationState = "not_published" | "published" | "unknown";
54
+
55
+ interface ExistingFileMetadata {
56
+ mode: number;
57
+ uid: number;
58
+ gid: number;
59
+ nlink: number;
60
+ dev: number;
61
+ ino: number;
62
+ }
63
+
64
+ interface ResolvedPublishPath {
65
+ publishPath: string;
66
+ existing?: ExistingFileMetadata;
67
+ }
68
+
69
+ export class FileWriteNotPublishedError extends Error {
70
+ readonly dest: string;
71
+ readonly destUnchanged: boolean;
72
+ readonly publicationState: FileWritePublicationState;
73
+ override readonly cause: unknown;
74
+ constructor(
75
+ dest: string,
76
+ cause: unknown,
77
+ options: { destUnchanged?: boolean; publicationState?: FileWritePublicationState } = {},
78
+ ) {
79
+ const destUnchanged = options.destUnchanged ?? true;
80
+ super(formatFileWriteError(cause, dest, { destUnchanged }));
81
+ this.name = "FileWriteNotPublishedError";
82
+ this.dest = dest;
83
+ this.destUnchanged = destUnchanged;
84
+ this.publicationState = options.publicationState ?? (destUnchanged ? "not_published" : "unknown");
85
+ this.cause = cause;
86
+ if (isFsError(cause)) {
87
+ (this as Error & { code?: string }).code = cause.code;
88
+ }
89
+ }
90
+ }
91
+
92
+ export function isFileWritePermissionError(error: unknown): boolean {
93
+ return isEacces(error) || hasFsCode(error, "EPERM") || hasFsCode(error, "EROFS");
94
+ }
95
+
96
+ export function formatFileWriteError(error: unknown, dest: string, options: { destUnchanged?: boolean } = {}): string {
97
+ if (error instanceof FileWriteNotPublishedError && options.destUnchanged !== false) return error.message;
98
+ if (isEisdir(error)) {
99
+ return `Cannot write '${dest}': path is a directory.`;
100
+ }
101
+ if (isFileWritePermissionError(error)) {
102
+ const code = isFsError(error) ? error.code : "EPERM";
103
+ const unchanged = options.destUnchanged
104
+ ? " The original file was left unchanged."
105
+ : " The destination may already have been replaced if a formatter published earlier in this write.";
106
+ return `Permission denied writing '${dest}' (${code}).${unchanged} Check directory write bits, file immutability, and any sandbox policy. Do not retry the same path through the shell tool.`;
107
+ }
108
+ return error instanceof Error ? error.message : String(error);
109
+ }
110
+
111
+ export interface WriteFileAtomicallyOptions {
112
+ /**
113
+ * Trusted root that a resolved symlink referent and its parent must not
114
+ * leave. When omitted, the helper still enforces the session-scoped
115
+ * `gjc-local` boundary implied by a lexical destination under
116
+ * `<tmpdir>/gjc-local/<session-id>`.
117
+ */
118
+ trustBoundary?: string;
119
+ /** Platform override used by deterministic retry tests. */
120
+ platform?: NodeJS.Platform;
121
+ /** Sleep seam used by deterministic retry tests. */
122
+ sleep?: (delayMs: number) => Promise<void>;
123
+ /** Test seam invoked after the fallback handle is opened and before mutation. */
124
+ beforeInPlaceMutation?: () => Promise<void>;
125
+ }
126
+
127
+ function tempPathFor(dest: string): string {
128
+ const unique = `${process.pid}.${Date.now().toString(36)}.${Math.random().toString(36).slice(2, 8)}`;
129
+ return path.join(path.dirname(dest), `.${path.basename(dest)}.${unique}.tmp`);
130
+ }
131
+
132
+ function eisdir(dest: string): Error & { code: string } {
133
+ const error = new Error(`EISDIR: illegal operation on a directory, write '${dest}'`) as Error & {
134
+ code: string;
135
+ };
136
+ error.code = "EISDIR";
137
+ return error;
138
+ }
139
+
140
+ async function resolvePublishPath(dest: string, depth = 0): Promise<ResolvedPublishPath> {
141
+ if (depth > 40) {
142
+ throw new Error(`ELOOP: too many symbolic links, write '${dest}'`);
143
+ }
144
+ try {
145
+ const lst = await fs.lstat(dest);
146
+ if (lst.isDirectory()) throw eisdir(dest);
147
+ if (lst.isSymbolicLink()) {
148
+ const target = await fs.readlink(dest);
149
+ return resolvePublishPath(path.resolve(path.dirname(dest), target), depth + 1);
150
+ }
151
+ return {
152
+ publishPath: dest,
153
+ existing: {
154
+ mode: lst.mode,
155
+ uid: lst.uid,
156
+ gid: lst.gid,
157
+ nlink: lst.nlink,
158
+ dev: lst.dev,
159
+ ino: lst.ino,
160
+ },
161
+ };
162
+ } catch (error) {
163
+ if (!isEnoent(error)) throw error;
164
+ return { publishPath: dest };
165
+ }
166
+ }
167
+
168
+ function pathIsWithin(target: string, root: string): boolean {
169
+ return target === root || target.startsWith(`${root}${path.sep}`);
170
+ }
171
+
172
+ async function realpathOrSelf(p: string): Promise<string> {
173
+ try {
174
+ return await fs.realpath(p);
175
+ } catch (error) {
176
+ if (!isEnoent(error)) throw error;
177
+ return path.resolve(p);
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Derive the session-scoped `local://` trust boundary from a lexical write
183
+ * destination. Session roots live at `<tmpdir>/gjc-local/<session-id>`, and a
184
+ * symlink placed inside one must not be able to redirect a write out of it.
185
+ */
186
+ function sessionLocalRootFor(lexicalDest: string): string | undefined {
187
+ const resolvedDest = path.resolve(lexicalDest);
188
+ const localParent = path.join(os.tmpdir(), "gjc-local");
189
+ if (!pathIsWithin(resolvedDest, localParent)) return undefined;
190
+ const rest = resolvedDest.slice(localParent.length + path.sep.length);
191
+ const sessionSegment = rest.split(path.sep, 1)[0] ?? "";
192
+ if (sessionSegment.length === 0) return undefined;
193
+ return path.join(localParent, sessionSegment);
194
+ }
195
+
196
+ /**
197
+ * Reject publication when the resolved referent's real parent (and therefore
198
+ * the referent itself) leaves the trust boundary. The boundary root is
199
+ * realpathed so a symlinked `gjc-local` root cannot smuggle a write out.
200
+ *
201
+ * This must run before any directory is created. A dangling symlink inside a
202
+ * trusted root resolves to a path outside it, so creating parents first would
203
+ * let an attacker-selected directory tree be materialized outside the sandbox
204
+ * before publication is refused. Missing ancestors therefore resolve
205
+ * lexically (`realpathOrSelf` tolerates ENOENT) and are still boundary-checked.
206
+ */
207
+ async function assertWithinTrustBoundary(publishPath: string, trustBoundary: string): Promise<void> {
208
+ const boundary = path.resolve(trustBoundary);
209
+ const realBoundary = await realpathOrSelf(boundary);
210
+ const realParent = await realpathOrSelf(path.dirname(publishPath));
211
+ if (!pathIsWithin(realParent, realBoundary)) {
212
+ throw new Error(`write target '${publishPath}' resolves outside trust boundary '${trustBoundary}'`);
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Identity of the publication parent directory. A realpath string alone cannot
218
+ * detect a parent that was unlinked and replaced by a different directory at
219
+ * the same path: the string still matches while the rename would publish into
220
+ * the replacement. Pin device and inode instead.
221
+ */
222
+ interface ParentIdentity {
223
+ realpath: string;
224
+ dev: number;
225
+ ino: number;
226
+ }
227
+
228
+ async function captureParentIdentity(publishPath: string): Promise<ParentIdentity> {
229
+ const parent = path.dirname(publishPath);
230
+ const realpath = await realpathOrSelf(parent);
231
+ const stat = await fs.stat(parent);
232
+ return { realpath, dev: stat.dev, ino: stat.ino };
233
+ }
234
+
235
+ /**
236
+ * Rename replaces the referent without consulting its file permissions, so a
237
+ * writable parent could otherwise overwrite a read-only or ACL-denied target in
238
+ * a way a direct write would not. Probe effective write authorization the way a
239
+ * direct write would: open the existing referent for append (requires write
240
+ * permission and mutates nothing). Native Windows read-only attributes surface
241
+ * as EPERM/EACCES here just like POSIX immutable/`0444` targets.
242
+ */
243
+ async function assertExistingTargetWritable(publishPath: string): Promise<void> {
244
+ const handle = await fs.open(publishPath, "a");
245
+ await handle.close();
246
+ }
247
+
248
+ function sameFileIdentity(left: ExistingFileMetadata, right: ExistingFileMetadata): boolean {
249
+ return (
250
+ left.dev === right.dev &&
251
+ left.ino === right.ino &&
252
+ left.nlink === right.nlink &&
253
+ left.mode === right.mode &&
254
+ left.uid === right.uid &&
255
+ left.gid === right.gid
256
+ );
257
+ }
258
+
259
+ function metadataFromStat(stat: {
260
+ mode: number;
261
+ uid: number;
262
+ gid: number;
263
+ nlink: number;
264
+ dev: number;
265
+ ino: number;
266
+ }): ExistingFileMetadata {
267
+ return {
268
+ mode: stat.mode,
269
+ uid: stat.uid,
270
+ gid: stat.gid,
271
+ nlink: stat.nlink,
272
+ dev: stat.dev,
273
+ ino: stat.ino,
274
+ };
275
+ }
276
+
277
+ async function readWholeFileAtPositionZero(handle: fs.FileHandle): Promise<Uint8Array> {
278
+ const stat = await handle.stat();
279
+ const bytes = new Uint8Array(stat.size);
280
+ let read = 0;
281
+ while (read < bytes.byteLength) {
282
+ const result = await handle.read(bytes, read, bytes.byteLength - read, read);
283
+ if (result.bytesRead === 0) throw new Error(`in-place read stalled at ${read} of ${bytes.byteLength} bytes`);
284
+ read += result.bytesRead;
285
+ }
286
+ return bytes;
287
+ }
288
+
289
+ async function preserveExistingMetadata(tmp: string, existing: ExistingFileMetadata): Promise<void> {
290
+ const staged = await fs.stat(tmp);
291
+ if (staged.uid !== existing.uid || staged.gid !== existing.gid) {
292
+ await fs.chown(tmp, existing.uid, existing.gid);
293
+ }
294
+ await fs.chmod(tmp, existing.mode);
295
+ }
296
+
297
+ async function cleanupOwnedTemp(tmp: string, cause: unknown): Promise<void> {
298
+ try {
299
+ await fs.unlink(tmp);
300
+ } catch (cleanupError) {
301
+ if (isEnoent(cleanupError)) return;
302
+ throw new AggregateError([cause, cleanupError], `Failed to clean up staging file '${tmp}'.`);
303
+ }
304
+ }
305
+
306
+ function isWindowsSharingViolation(error: unknown): boolean {
307
+ return isFsError(error) && WINDOWS_SHARING_VIOLATION_CODES.has(error.code);
308
+ }
309
+
310
+ async function renameIntoPlace(
311
+ from: string,
312
+ to: string,
313
+ platform: NodeJS.Platform,
314
+ sleep: (delayMs: number) => Promise<void>,
315
+ ): Promise<void> {
316
+ try {
317
+ await fs.rename(from, to);
318
+ return;
319
+ } catch (error) {
320
+ if (platform !== "win32" || !isWindowsSharingViolation(error)) throw error;
321
+ let lastError: unknown = error;
322
+ for (const delay of WINDOWS_RENAME_BACKOFF_MS) {
323
+ await sleep(delay);
324
+ try {
325
+ await fs.rename(from, to);
326
+ return;
327
+ } catch (retryError) {
328
+ lastError = retryError;
329
+ if (!isWindowsSharingViolation(retryError)) throw retryError;
330
+ }
331
+ }
332
+ throw lastError;
333
+ }
334
+ }
335
+
336
+ /**
337
+ * Windows permits another process to share writes while denying delete/rename.
338
+ * In that narrow case preserve the existing inode and perform a rollback-capable
339
+ * in-place replacement. Hard-linked files never enter this path: they are
340
+ * rejected before staging because a failed write cannot safely preserve every
341
+ * alias with a pathname replacement contract.
342
+ */
343
+ /**
344
+ * Write `bytes` as the file's entire leading content starting at absolute
345
+ * position 0, looping until every byte is accepted. `FileHandle.writeFile()`
346
+ * writes from the handle's current position, which makes it unsafe for a
347
+ * rollback that must reproduce the original bytes exactly.
348
+ */
349
+ async function writeWholeFileAtPositionZero(handle: fs.FileHandle, bytes: Uint8Array): Promise<void> {
350
+ let written = 0;
351
+ while (written < bytes.byteLength) {
352
+ const result = await handle.write(bytes, written, bytes.byteLength - written, written);
353
+ if (result.bytesWritten === 0)
354
+ throw new Error(`in-place write stalled at ${written} of ${bytes.byteLength} bytes`);
355
+ written += result.bytesWritten;
356
+ }
357
+ }
358
+
359
+ async function replaceInPlaceAfterSharingViolation(
360
+ dest: string,
361
+ tmp: string,
362
+ platform: NodeJS.Platform,
363
+ expectedExisting: ExistingFileMetadata,
364
+ beforeMutation?: () => Promise<void>,
365
+ ): Promise<void> {
366
+ if (platform !== "win32") throw new Error("in-place sharing fallback is Windows-only");
367
+ // This fallback mutates the destination by pathname instead of publishing a
368
+ // staged inode, so it must re-establish that the pathname still names the
369
+ // file we were authorized to replace. Rename retries and their backoff give a
370
+ // concurrent writer time to substitute a different inode; overwriting that
371
+ // one in place would be an unauthorized mutation with no rollback source.
372
+ // Open the pathname first, then bind all reads and writes to that handle. A
373
+ // pathname read followed by a later open can read one inode and mutate its
374
+ // successor. The handle identity and pathname identity are both checked
375
+ // immediately before the first mutation; a race therefore fails closed and
376
+ // never writes the successor.
377
+ const replacement = new Uint8Array(await Bun.file(tmp).arrayBuffer());
378
+ const handle = await fs.open(dest, "r+");
379
+ let original: Uint8Array | undefined;
380
+ let failure: unknown;
381
+ let committed = false;
382
+ let mutationStarted = false;
383
+ try {
384
+ try {
385
+ const opened = metadataFromStat(await handle.stat());
386
+ if (!sameFileIdentity(opened, expectedExisting)) {
387
+ throw new FileWriteNotPublishedError(
388
+ dest,
389
+ new Error(`destination '${dest}' changed before the in-place fallback was opened`),
390
+ { destUnchanged: true, publicationState: "not_published" },
391
+ );
392
+ }
393
+ original = await readWholeFileAtPositionZero(handle);
394
+ if (beforeMutation) await beforeMutation();
395
+ const current = (await resolvePublishPath(dest)).existing;
396
+ const bound = metadataFromStat(await handle.stat());
397
+ if (
398
+ current === undefined ||
399
+ !sameFileIdentity(current, expectedExisting) ||
400
+ !sameFileIdentity(bound, expectedExisting)
401
+ ) {
402
+ throw new FileWriteNotPublishedError(
403
+ dest,
404
+ new Error(
405
+ `destination '${dest}' was replaced before the in-place fallback mutation; refusing to overwrite a different file`,
406
+ ),
407
+ { destUnchanged: true, publicationState: "not_published" },
408
+ );
409
+ }
410
+ // Write at an explicit absolute position: handle.writeFile() appends from
411
+ // the handle's current offset, so a retry or rollback after a partial
412
+ // write would otherwise land mid-file and interleave bytes.
413
+ mutationStarted = true;
414
+ await writeWholeFileAtPositionZero(handle, replacement);
415
+ await handle.sync();
416
+ await handle.truncate(replacement.byteLength);
417
+ await handle.sync();
418
+ committed = true;
419
+ } catch (error) {
420
+ if (original !== undefined && mutationStarted) {
421
+ try {
422
+ await writeWholeFileAtPositionZero(handle, original);
423
+ await handle.truncate(original.byteLength);
424
+ await handle.sync();
425
+ } catch (rollbackError) {
426
+ failure = new FileWriteNotPublishedError(
427
+ dest,
428
+ new AggregateError([error, rollbackError], "In-place write rollback failed."),
429
+ { destUnchanged: false, publicationState: "unknown" },
430
+ );
431
+ }
432
+ }
433
+ if (failure === undefined) failure = error;
434
+ }
435
+ } finally {
436
+ try {
437
+ await handle.close();
438
+ } catch (closeError) {
439
+ if (committed) {
440
+ failure = new FileWriteNotPublishedError(dest, closeError, {
441
+ destUnchanged: false,
442
+ publicationState: "published",
443
+ });
444
+ } else if (failure === undefined) {
445
+ failure = closeError;
446
+ }
447
+ }
448
+ }
449
+ if (failure !== undefined) {
450
+ if (failure instanceof FileWriteNotPublishedError) throw failure;
451
+ throw new FileWriteNotPublishedError(dest, failure, {
452
+ destUnchanged: !committed,
453
+ publicationState: committed ? "published" : "not_published",
454
+ });
455
+ }
456
+ }
457
+
458
+ /**
459
+ * Revalidate the resolved destination immediately before publication so a
460
+ * symlink retargeted while staging cannot silently repoint the write at a
461
+ * different file.
462
+ */
463
+ async function assertPublishTargetStillIntended(
464
+ dest: string,
465
+ publishPath: string,
466
+ trustBoundary: string | undefined,
467
+ expectedExisting: ExistingFileMetadata | undefined,
468
+ expectedParent: ParentIdentity,
469
+ ): Promise<void> {
470
+ const after = await resolvePublishPath(dest);
471
+ if (after.publishPath !== publishPath) {
472
+ throw new Error(`destination '${dest}' was retargeted while staging; refusing to overwrite a different file`);
473
+ }
474
+ const currentParent = await captureParentIdentity(publishPath);
475
+ if (
476
+ currentParent.realpath !== expectedParent.realpath ||
477
+ currentParent.dev !== expectedParent.dev ||
478
+ currentParent.ino !== expectedParent.ino
479
+ ) {
480
+ throw new Error(
481
+ `destination '${dest}' parent was retargeted while staging; refusing to overwrite a different file`,
482
+ );
483
+ }
484
+ if (expectedExisting === undefined) {
485
+ if (after.existing !== undefined) {
486
+ throw new Error(`destination '${dest}' appeared while staging; refusing to overwrite a different file`);
487
+ }
488
+ } else if (after.existing === undefined || !sameFileIdentity(after.existing, expectedExisting)) {
489
+ throw new Error(`destination '${dest}' was replaced while staging; refusing to overwrite a different file`);
490
+ }
491
+ if (trustBoundary !== undefined) {
492
+ await assertWithinTrustBoundary(after.publishPath, trustBoundary);
493
+ }
494
+ }
495
+
496
+ export async function writeFileAtomically(
497
+ dest: string,
498
+ content: string | Uint8Array,
499
+ options: WriteFileAtomicallyOptions = {},
500
+ ): Promise<void> {
501
+ let publishPath = dest;
502
+ try {
503
+ const platform = options.platform ?? process.platform;
504
+ const sleep = options.sleep ?? (async (delayMs: number): Promise<void> => await Bun.sleep(delayMs));
505
+ const trustBoundary = options.trustBoundary ?? sessionLocalRootFor(dest);
506
+ const resolved = await resolvePublishPath(dest);
507
+ publishPath = resolved.publishPath;
508
+ // Boundary first, then create parents. A dangling symlink inside a trusted
509
+ // root resolves outside it, so creating directories before this check would
510
+ // materialize an attacker-selected tree outside the sandbox and only then
511
+ // refuse to publish.
512
+ if (trustBoundary !== undefined) {
513
+ await assertWithinTrustBoundary(publishPath, trustBoundary);
514
+ }
515
+ await fs.mkdir(path.dirname(publishPath), { recursive: true });
516
+ // Re-check after creation: `mkdir -p` follows existing symlinked ancestors,
517
+ // so the post-creation parent is the one publication must be bound to.
518
+ if (trustBoundary !== undefined) {
519
+ await assertWithinTrustBoundary(publishPath, trustBoundary);
520
+ }
521
+ const expectedParent = await captureParentIdentity(publishPath);
522
+ const existing = resolved.existing;
523
+ if (existing !== undefined && existing.nlink > 1) {
524
+ throw new Error(
525
+ `Cannot atomically replace hard-linked file '${dest}': replacement would split its link group.`,
526
+ );
527
+ }
528
+ if (existing !== undefined) {
529
+ await assertExistingTargetWritable(publishPath);
530
+ }
531
+ let lastError: unknown;
532
+ for (let attempt = 0; attempt < TEMP_CREATE_ATTEMPTS; attempt++) {
533
+ const tmp = tempPathFor(publishPath);
534
+ let owned = false;
535
+ try {
536
+ const handle = await fs.open(tmp, "wx", existing?.mode ?? DEFAULT_FILE_MODE);
537
+ owned = true;
538
+ try {
539
+ await handle.writeFile(content);
540
+ await handle.sync();
541
+ } finally {
542
+ await handle.close();
543
+ }
544
+ if (existing !== undefined) {
545
+ await preserveExistingMetadata(tmp, existing);
546
+ }
547
+ await assertPublishTargetStillIntended(dest, publishPath, trustBoundary, existing, expectedParent);
548
+ // The staged file lives beside the destination, so rename is one
549
+ // same-directory atomic publication. A failed rename leaves the
550
+ // destination untouched and the owned staging file is cleaned below.
551
+ try {
552
+ await renameIntoPlace(tmp, publishPath, platform, sleep);
553
+ } catch (error) {
554
+ if (existing !== undefined && platform === "win32" && isWindowsSharingViolation(error)) {
555
+ await replaceInPlaceAfterSharingViolation(
556
+ publishPath,
557
+ tmp,
558
+ platform,
559
+ existing,
560
+ options.beforeInPlaceMutation,
561
+ );
562
+ try {
563
+ await fs.unlink(tmp);
564
+ } catch (cleanupError) {
565
+ owned = false;
566
+ throw new FileWriteNotPublishedError(
567
+ dest,
568
+ new AggregateError([error, cleanupError], "Published bytes could not be cleaned up."),
569
+ { destUnchanged: false, publicationState: "published" },
570
+ );
571
+ }
572
+ owned = false;
573
+ return;
574
+ }
575
+ throw error;
576
+ }
577
+ owned = false;
578
+ return;
579
+ } catch (error) {
580
+ lastError = error;
581
+ // A temp that never got created was a genuine pre-existing
582
+ // collision file: leave it alone and try a fresh sibling name.
583
+ if (hasFsCode(error, "EEXIST") && !owned) continue;
584
+ // Any failure after we exclusively created the temp must not leak it.
585
+ if (owned) await cleanupOwnedTemp(tmp, error);
586
+ throw error;
587
+ }
588
+ }
589
+ throw lastError;
590
+ } catch (error) {
591
+ if (error instanceof FileWriteNotPublishedError) throw error;
592
+ throw new FileWriteNotPublishedError(dest, error);
593
+ }
594
+ }
@@ -89,7 +89,7 @@ export interface BisectToolDetails {
89
89
  reason?: string;
90
90
  }
91
91
 
92
- const FIRST_BAD_RE = /^([0-9a-f]{7,40}) is the first bad commit$/m;
92
+ const FIRST_BAD_RE = /^\s*([0-9a-f]{7,40})\s+is the first (?:bad|'bad') commit\b/m;
93
93
  const ONLY_SKIPPED_RE = /only '?skip'?ped commits left to test/i;
94
94
 
95
95
  /** Parse the culprit SHA from `git bisect good|bad` output, or null when not yet converged. */
@@ -266,7 +266,7 @@ export class BisectTool implements AgentTool<typeof bisectSchema, BisectToolDeta
266
266
  );
267
267
  }
268
268
 
269
- let outcome: BisectOutcome;
269
+ let outcome: BisectOutcome | undefined;
270
270
  // Defensive reset in case a previous aborted bisect left state behind; a
271
271
  // no-op when not bisecting (reset never throws).
272
272
  await git.bisect.reset(cwd, signal);
@@ -318,6 +318,7 @@ export class BisectTool implements AgentTool<typeof bisectSchema, BisectToolDeta
318
318
  await git.reset(cwd, { hard: true }).catch(() => {});
319
319
  await git.bisect.reset(cwd);
320
320
  }
321
+ if (!outcome) throw new ToolError("git bisect ended without producing a result.");
321
322
 
322
323
  // Never claim a clean restore we did not actually achieve.
323
324
  const restoreLine = await describeRestore(cwd);
@@ -56,6 +56,7 @@ function makeSession(overrides: Record<string, unknown> = {}): any {
56
56
  requireYieldTool: false,
57
57
  enableLsp: true,
58
58
  taskDepth: 0,
59
+ rescopeSessionCwd: async () => ({ from: process.cwd(), to: process.cwd() }),
59
60
  getSessionFile: () => null,
60
61
  getSessionSpawns: () => null,
61
62
  };
@@ -99,6 +100,7 @@ describe("tool descriptor compatibility gate", () => {
99
100
  "debug",
100
101
  "bisect",
101
102
  "eval",
103
+ "python",
102
104
  "calc",
103
105
  "ssh",
104
106
  "github",
@@ -124,6 +126,7 @@ describe("tool descriptor compatibility gate", () => {
124
126
  "write",
125
127
  "skill",
126
128
  "goal",
129
+ "move_session",
127
130
  ];
128
131
  expect(Object.keys(BUILTIN_TOOLS)).toEqual(expectedBuiltin);
129
132
  expect(Object.keys(BUILTIN_TOOL_DESCRIPTORS)).toEqual(expectedBuiltin);