@gajae-code/coding-agent 0.18.11 → 0.19.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 (462) hide show
  1. package/CHANGELOG.md +760 -0
  2. package/dist/types/async/job-manager.d.ts +7 -3
  3. package/dist/types/async/result-formatting.d.ts +2 -0
  4. package/dist/types/autoresearch/git.d.ts +1 -1
  5. package/dist/types/capability/fs.d.ts +2 -2
  6. package/dist/types/capability/rule.d.ts +7 -2
  7. package/dist/types/cli/accounts-cli.d.ts +2 -2
  8. package/dist/types/cli/append-system-prompt.d.ts +6 -0
  9. package/dist/types/cli/args.d.ts +7 -2
  10. package/dist/types/cli/customize-doctor-precedence.d.ts +16 -0
  11. package/dist/types/cli/customize-doctor.d.ts +4 -3
  12. package/dist/types/cli/doctor/install-repairs.d.ts +4 -1
  13. package/dist/types/cli/install-activation.d.ts +10 -4
  14. package/dist/types/cli/public-command-errors.d.ts +2 -0
  15. package/dist/types/cli/root-flags.d.ts +0 -12
  16. package/dist/types/cli/update-cli.d.ts +21 -3
  17. package/dist/types/cli-main.d.ts +0 -6
  18. package/dist/types/cli-ordinary.d.ts +2 -2
  19. package/dist/types/commands/accounts.d.ts +2 -1
  20. package/dist/types/commands/launch.d.ts +0 -6
  21. package/dist/types/commands/migrate.d.ts +1 -1
  22. package/dist/types/commands/sdk.d.ts +13 -0
  23. package/dist/types/commit/changelog/index.d.ts +1 -0
  24. package/dist/types/commit/map-reduce/index.d.ts +4 -4
  25. package/dist/types/config/autorouting-tier-map.d.ts +53 -1
  26. package/dist/types/config/config-hot-reload-trust.d.ts +12 -0
  27. package/dist/types/config/config-hot-reload.d.ts +44 -0
  28. package/dist/types/config/file-lock.d.ts +52 -3
  29. package/dist/types/config/model-discovery-manager.d.ts +2 -0
  30. package/dist/types/config/model-profile-activation.d.ts +79 -9
  31. package/dist/types/config/model-registry.d.ts +72 -8
  32. package/dist/types/config/model-resolver.d.ts +7 -4
  33. package/dist/types/config/models-config-schema.d.ts +10 -0
  34. package/dist/types/config/settings-schema.d.ts +57 -4
  35. package/dist/types/config/settings.d.ts +33 -1
  36. package/dist/types/coordinator-mcp/server.d.ts +4 -0
  37. package/dist/types/customization/import.d.ts +2 -0
  38. package/dist/types/customization/types.d.ts +1 -1
  39. package/dist/types/dap/config.d.ts +8 -4
  40. package/dist/types/dap/session.d.ts +11 -2
  41. package/dist/types/debug/system-info.d.ts +6 -1
  42. package/dist/types/discovery/helpers.d.ts +7 -2
  43. package/dist/types/discovery/mcp-compat.d.ts +3 -1
  44. package/dist/types/edit/index.d.ts +5 -0
  45. package/dist/types/edit/modes/patch.d.ts +14 -0
  46. package/dist/types/edit/modes/replace.d.ts +3 -0
  47. package/dist/types/eval/py/runtime.d.ts +9 -0
  48. package/dist/types/exec/bash-executor.d.ts +12 -0
  49. package/dist/types/export/ttsr.d.ts +19 -0
  50. package/dist/types/extensibility/extensions/types.d.ts +5 -1
  51. package/dist/types/extensibility/slash-commands.d.ts +1 -0
  52. package/dist/types/gjc-runtime/managed-owner-admission.d.ts +3 -0
  53. package/dist/types/gjc-runtime/ralplan-current-final.d.ts +54 -0
  54. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +5 -0
  55. package/dist/types/gjc-runtime/session-state-lock.d.ts +2 -0
  56. package/dist/types/gjc-runtime/state-runtime.d.ts +0 -2
  57. package/dist/types/gjc-runtime/tmux-sessions.d.ts +24 -0
  58. package/dist/types/gjc-runtime/ultragoal-change-set.d.ts +6 -0
  59. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +1 -0
  60. package/dist/types/harness-control-plane/receipt-spool.d.ts +4 -0
  61. package/dist/types/hindsight/content.d.ts +5 -6
  62. package/dist/types/internal-urls/types.d.ts +2 -0
  63. package/dist/types/lsp/clients/swiftlint-client.d.ts +0 -4
  64. package/dist/types/main.d.ts +23 -18
  65. package/dist/types/migrate/action-planner.d.ts +10 -1
  66. package/dist/types/migrate/adapters/codex.d.ts +2 -1
  67. package/dist/types/migrate/adapters/index.d.ts +42 -1
  68. package/dist/types/migrate/executor.d.ts +3 -1
  69. package/dist/types/migrate/hook-module.d.ts +23 -0
  70. package/dist/types/migrate/report.d.ts +2 -1
  71. package/dist/types/migrate/types.d.ts +82 -3
  72. package/dist/types/modes/components/agent-dashboard.d.ts +1 -1
  73. package/dist/types/modes/components/model-selector.d.ts +43 -0
  74. package/dist/types/modes/components/settings-selector.d.ts +6 -0
  75. package/dist/types/modes/controllers/selector-controller.d.ts +2 -0
  76. package/dist/types/modes/prompt-suggestion-controller.d.ts +1 -1
  77. package/dist/types/modes/theme/theme.d.ts +1 -0
  78. package/dist/types/modes/utils/host-status.d.ts +15 -11
  79. package/dist/types/runtime-mcp/content-limits.d.ts +4 -0
  80. package/dist/types/runtime-mcp/manager.d.ts +7 -0
  81. package/dist/types/runtime-mcp/plugin-mcp-name-filter.d.ts +22 -0
  82. package/dist/types/runtime-mcp/project-host-values.d.ts +11 -5
  83. package/dist/types/runtime-mcp/tool-bridge.d.ts +4 -0
  84. package/dist/types/sdk/broker/broker-exit.d.ts +3 -3
  85. package/dist/types/sdk/broker/broker.d.ts +29 -2
  86. package/dist/types/sdk/broker/daemon-entry.d.ts +11 -1
  87. package/dist/types/sdk/broker/doctor-restart.d.ts +1 -1
  88. package/dist/types/sdk/broker/ensure.d.ts +141 -9
  89. package/dist/types/sdk/broker/hop.d.ts +45 -0
  90. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +18 -10
  91. package/dist/types/sdk/broker/lifecycle.d.ts +22 -0
  92. package/dist/types/sdk/broker/lock-host-identity.d.ts +5 -0
  93. package/dist/types/sdk/broker/session-index.d.ts +6 -1
  94. package/dist/types/sdk/broker/transport.d.ts +2 -0
  95. package/dist/types/sdk/bus/chat-command-policy.d.ts +2 -1
  96. package/dist/types/sdk/bus/index.d.ts +44 -0
  97. package/dist/types/sdk/bus/reconciliation-store.d.ts +10 -0
  98. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +9 -1
  99. package/dist/types/sdk/bus/telegram-daemon.d.ts +2 -0
  100. package/dist/types/sdk/bus/threaded-render.d.ts +2 -1
  101. package/dist/types/sdk/cli/session-cli.d.ts +0 -1
  102. package/dist/types/sdk/host/control/operations.d.ts +5 -1
  103. package/dist/types/sdk/host/events.d.ts +4 -0
  104. package/dist/types/sdk/host/host.d.ts +2 -1
  105. package/dist/types/sdk/host/prompt-image-upload.d.ts +43 -0
  106. package/dist/types/sdk/host/session-runtime.d.ts +40 -8
  107. package/dist/types/sdk/host/surface-policy.d.ts +2 -1
  108. package/dist/types/sdk/lifecycle/service.d.ts +12 -0
  109. package/dist/types/sdk/prompt-deadline-flush.d.ts +42 -4
  110. package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -0
  111. package/dist/types/sdk/prompt-status.d.ts +11 -4
  112. package/dist/types/sdk/protocol/adapter-validation.d.ts +10 -4
  113. package/dist/types/sdk/reconciliation-extensions.d.ts +1 -1
  114. package/dist/types/sdk/session-list.d.ts +1 -1
  115. package/dist/types/session/agent-session.d.ts +107 -9
  116. package/dist/types/session/internal/managed-session-scope.d.ts +2 -2
  117. package/dist/types/session/internal/managed-session-storage.d.ts +1 -1
  118. package/dist/types/session/internal/native-publish-outcome.d.ts +1 -1
  119. package/dist/types/session/messages.d.ts +5 -1
  120. package/dist/types/session/session-manager.d.ts +29 -3
  121. package/dist/types/session/startup-auth-config.d.ts +24 -2
  122. package/dist/types/session/streaming-output.d.ts +11 -0
  123. package/dist/types/session/terminal-abort.d.ts +3 -1
  124. package/dist/types/session-import/service.d.ts +1 -1
  125. package/dist/types/skill-state/workflow-mutation-guard.d.ts +20 -4
  126. package/dist/types/slash-commands/helpers/mcp.d.ts +5 -0
  127. package/dist/types/slash-commands/helpers/provider-auth-status.d.ts +8 -0
  128. package/dist/types/ssh/ssh-executor.d.ts +8 -1
  129. package/dist/types/system-prompt.d.ts +3 -2
  130. package/dist/types/task/executor.d.ts +19 -2
  131. package/dist/types/task/role-body.d.ts +11 -0
  132. package/dist/types/tools/ast-edit-write-path.d.ts +10 -0
  133. package/dist/types/tools/atomic-file-write.d.ts +9 -7
  134. package/dist/types/tools/awaiting-input.d.ts +28 -0
  135. package/dist/types/tools/bash-allowed-prefixes.d.ts +17 -0
  136. package/dist/types/tools/bash-skill-urls.d.ts +4 -0
  137. package/dist/types/tools/bash.d.ts +7 -0
  138. package/dist/types/tools/descriptors.d.ts +15 -2
  139. package/dist/types/tools/fetch.d.ts +7 -1
  140. package/dist/types/tools/index.d.ts +2 -0
  141. package/dist/types/tools/irc.d.ts +5 -3
  142. package/dist/types/tools/python.d.ts +1 -1
  143. package/dist/types/tools/report-issue.d.ts +41 -0
  144. package/dist/types/tools/subagent.d.ts +23 -0
  145. package/dist/types/tools/tmux-self-injection-guard.d.ts +2 -0
  146. package/dist/types/tools/ultragoal-ask-guard.d.ts +1 -1
  147. package/dist/types/tools/write.d.ts +2 -0
  148. package/dist/types/utils/file-mentions.d.ts +5 -0
  149. package/dist/types/utils/image-limits.d.ts +7 -0
  150. package/dist/types/utils/image-loading.d.ts +0 -1
  151. package/dist/types/utils/open.d.ts +0 -1
  152. package/dist/types/utils/pasted-image-loading.d.ts +1 -5
  153. package/dist/types/utils/prompt-suggestion.d.ts +2 -2
  154. package/dist/types/utils/shell-snapshot.d.ts +2 -0
  155. package/dist/types/utils/title-generator.d.ts +13 -3
  156. package/dist/types/web/scrapers/types.d.ts +8 -1
  157. package/dist/types/web/search/providers/codex.d.ts +6 -7
  158. package/dist/types/web/search/providers/insane.d.ts +7 -1
  159. package/dist/types/web/search/providers/searxng.d.ts +5 -0
  160. package/package.json +10 -9
  161. package/scripts/acp-conformance-agent.ts +12 -2
  162. package/scripts/build-binary.ts +2 -1
  163. package/scripts/capture-subagent-compact-panel-showcase.ts +105 -0
  164. package/scripts/generate-docs-index.ts +8 -1
  165. package/scripts/generate-sdk-operation-inventory.ts +38 -4
  166. package/src/async/job-manager.ts +356 -56
  167. package/src/async/result-formatting.ts +40 -0
  168. package/src/autoresearch/git.ts +9 -2
  169. package/src/autoresearch/prompts/prompt-setup.md +1 -1
  170. package/src/capability/fs.ts +58 -22
  171. package/src/capability/index.ts +2 -2
  172. package/src/capability/rule.ts +11 -2
  173. package/src/cli/accounts-cli.ts +176 -8
  174. package/src/cli/append-system-prompt.ts +12 -0
  175. package/src/cli/args.ts +11 -7
  176. package/src/cli/auth-broker-cli.ts +21 -11
  177. package/src/cli/customize-doctor-precedence.ts +31 -0
  178. package/src/cli/customize-doctor.ts +49 -39
  179. package/src/cli/doctor/install-repairs.ts +35 -5
  180. package/src/cli/fast-help.ts +1 -3
  181. package/src/cli/install-activation.ts +47 -4
  182. package/src/cli/migrate-cli.ts +19 -6
  183. package/src/cli/public-command-errors.ts +6 -0
  184. package/src/cli/root-flags.ts +2 -4
  185. package/src/cli/stats-cli.ts +9 -5
  186. package/src/cli/update-cli.ts +471 -13
  187. package/src/cli-ordinary.ts +7 -2
  188. package/src/commands/accounts.ts +10 -5
  189. package/src/commands/harness.ts +25 -15
  190. package/src/commands/migrate.ts +3 -3
  191. package/src/commands/sdk.ts +277 -50
  192. package/src/commands/ultragoal.ts +16 -8
  193. package/src/commit/agentic/agent.ts +6 -6
  194. package/src/commit/analysis/conventional.ts +14 -5
  195. package/src/commit/changelog/index.ts +39 -1
  196. package/src/commit/map-reduce/index.ts +7 -7
  197. package/src/commit/message.ts +10 -3
  198. package/src/commit/pipeline.ts +12 -12
  199. package/src/config/autorouting-tier-map.ts +50 -1
  200. package/src/config/config-file.ts +106 -3
  201. package/src/config/config-hot-reload-trust.ts +148 -0
  202. package/src/config/config-hot-reload.ts +742 -0
  203. package/src/config/file-lock.ts +1615 -328
  204. package/src/config/keybindings.ts +154 -7
  205. package/src/config/model-bindings-applier.ts +85 -47
  206. package/src/config/model-discovery-manager.ts +10 -0
  207. package/src/config/model-profile-activation.ts +889 -148
  208. package/src/config/model-registry.ts +1706 -205
  209. package/src/config/model-resolver.ts +52 -22
  210. package/src/config/models-config-schema.ts +6 -0
  211. package/src/config/settings-schema.ts +73 -4
  212. package/src/config/settings.ts +460 -24
  213. package/src/coordinator-mcp/policy.ts +26 -1
  214. package/src/coordinator-mcp/server.ts +37 -2
  215. package/src/crash/sanitize.ts +7 -1
  216. package/src/customization/import.ts +279 -8
  217. package/src/customization/inventory.ts +24 -8
  218. package/src/customization/types.ts +1 -0
  219. package/src/dap/client.ts +3 -2
  220. package/src/dap/config.ts +31 -11
  221. package/src/dap/defaults.json +1 -1
  222. package/src/dap/session.ts +161 -36
  223. package/src/debug/crash-diagnostics.ts +89 -6
  224. package/src/debug/report-bundle.ts +90 -2
  225. package/src/debug/system-info.ts +8 -3
  226. package/src/defaults/gjc/extensions/grok-cli-vendor/src/shared/base-url.ts +21 -7
  227. package/src/discovery/agents-md.ts +1 -1
  228. package/src/discovery/builtin.ts +4 -3
  229. package/src/discovery/helpers.ts +25 -16
  230. package/src/discovery/mcp-compat.ts +120 -17
  231. package/src/edit/apply-patch/index.ts +12 -1
  232. package/src/edit/index.ts +204 -22
  233. package/src/edit/modes/patch.ts +214 -8
  234. package/src/edit/modes/replace.ts +136 -0
  235. package/src/eval/js/context-manager.ts +29 -3
  236. package/src/eval/js/worker-entry.ts +51 -18
  237. package/src/eval/py/executor.ts +14 -4
  238. package/src/eval/py/runner.py +144 -5
  239. package/src/eval/py/runtime.ts +44 -4
  240. package/src/exec/bash-executor.ts +81 -4
  241. package/src/exec/bash-shell-guardian.ts +4 -1
  242. package/src/exec/bash-shell-supervisor.ts +8 -4
  243. package/src/exec/isolated-shell.ts +5 -1
  244. package/src/exec/non-interactive-env.ts +0 -1
  245. package/src/export/html/index.ts +86 -6
  246. package/src/export/ttsr.ts +176 -1
  247. package/src/extensibility/custom-tools/wrapper.ts +3 -1
  248. package/src/extensibility/extensions/loader.ts +10 -1
  249. package/src/extensibility/extensions/ouroboros-ooo-bridge.ts +63 -1
  250. package/src/extensibility/extensions/types.ts +5 -1
  251. package/src/extensibility/hooks/loader.ts +10 -2
  252. package/src/extensibility/plugins/marketplace/fetcher.ts +2 -2
  253. package/src/extensibility/plugins/marketplace/manager.ts +36 -10
  254. package/src/extensibility/slash-commands.ts +5 -0
  255. package/src/gjc-runtime/gc-runtime.ts +37 -7
  256. package/src/gjc-runtime/launch-tmux.ts +30 -15
  257. package/src/gjc-runtime/launch-worktree.test.ts +148 -0
  258. package/src/gjc-runtime/launch-worktree.ts +101 -14
  259. package/src/gjc-runtime/managed-owner-admission.ts +46 -2
  260. package/src/gjc-runtime/ralplan-current-final.ts +294 -0
  261. package/src/gjc-runtime/ralplan-runtime.ts +95 -1
  262. package/src/gjc-runtime/session-resolution.ts +38 -6
  263. package/src/gjc-runtime/session-state-lock.ts +59 -14
  264. package/src/gjc-runtime/state-runtime.ts +23 -19
  265. package/src/gjc-runtime/state-writer.ts +109 -5
  266. package/src/gjc-runtime/tmux-sessions.ts +93 -13
  267. package/src/gjc-runtime/ultragoal-change-set.ts +90 -11
  268. package/src/gjc-runtime/ultragoal-guard.ts +9 -1
  269. package/src/gjc-runtime/ultragoal-runtime.ts +15 -5
  270. package/src/harness-control-plane/finalize.ts +11 -0
  271. package/src/harness-control-plane/receipt-spool.ts +105 -28
  272. package/src/harness-control-plane/storage.ts +124 -6
  273. package/src/hindsight/content.ts +116 -16
  274. package/src/hindsight/mental-models.ts +22 -10
  275. package/src/hindsight/state.ts +7 -4
  276. package/src/hooks/native-skill-hook.ts +17 -12
  277. package/src/internal-urls/artifact-protocol.ts +3 -1
  278. package/src/internal-urls/docs-index.generated.ts +23 -22
  279. package/src/internal-urls/local-protocol.ts +167 -8
  280. package/src/internal-urls/types.ts +2 -0
  281. package/src/lsp/client.ts +2 -1
  282. package/src/lsp/clients/biome-client.ts +2 -0
  283. package/src/lsp/clients/swiftlint-client.ts +2 -0
  284. package/src/lsp/index.ts +29 -0
  285. package/src/lsp/workspace-path.ts +23 -0
  286. package/src/main.ts +330 -92
  287. package/src/memories/index.ts +25 -2
  288. package/src/migrate/action-planner.ts +574 -2
  289. package/src/migrate/adapters/claude-code.ts +162 -5
  290. package/src/migrate/adapters/codex.ts +77 -7
  291. package/src/migrate/adapters/index.ts +258 -5
  292. package/src/migrate/adapters/opencode.ts +10 -1
  293. package/src/migrate/executor.ts +222 -7
  294. package/src/migrate/hook-module.ts +193 -0
  295. package/src/migrate/report.ts +63 -2
  296. package/src/migrate/types.ts +84 -4
  297. package/src/modes/DESIGN.md +51 -0
  298. package/src/modes/acp/acp-agent.ts +10 -1
  299. package/src/modes/acp/permission-mode.ts +18 -1
  300. package/src/modes/components/agent-dashboard.ts +20 -4
  301. package/src/modes/components/assistant-message.ts +55 -1
  302. package/src/modes/components/customization/customization-dashboard.ts +7 -0
  303. package/src/modes/components/customization/import-wizard.ts +29 -3
  304. package/src/modes/components/model-selector.ts +157 -103
  305. package/src/modes/components/settings-selector.ts +103 -37
  306. package/src/modes/components/welcome.ts +3 -1
  307. package/src/modes/controllers/command-controller.ts +300 -9
  308. package/src/modes/controllers/event-controller.ts +1 -1
  309. package/src/modes/controllers/extension-ui-controller.ts +31 -1
  310. package/src/modes/controllers/goal-mode-controller.ts +79 -10
  311. package/src/modes/controllers/input-controller.ts +2 -0
  312. package/src/modes/controllers/plan-mode-controller.ts +66 -11
  313. package/src/modes/controllers/runtime-mcp-command-controller.ts +7 -6
  314. package/src/modes/controllers/selector-controller.ts +76 -7
  315. package/src/modes/interactive-mode.ts +123 -4
  316. package/src/modes/prompt-suggestion-controller.ts +1 -1
  317. package/src/modes/runtime-init.ts +5 -1
  318. package/src/modes/theme/theme.ts +15 -0
  319. package/src/modes/utils/host-status.ts +21 -10
  320. package/src/prompts/agent-fragments/browser-aside.md +2 -2
  321. package/src/prompts/system/irc-incoming.md +3 -1
  322. package/src/prompts/tools/debug.md +1 -1
  323. package/src/prompts/tools/irc.md +1 -1
  324. package/src/prompts/tools/read.md +1 -1
  325. package/src/prompts/tools/report-issue.md +13 -0
  326. package/src/prompts/tools/task.md +1 -1
  327. package/src/runtime-credential-selector.ts +2 -2
  328. package/src/runtime-mcp/content-limits.ts +7 -0
  329. package/src/runtime-mcp/manager-pool.test.ts +5 -4
  330. package/src/runtime-mcp/manager.ts +40 -3
  331. package/src/runtime-mcp/oauth-flow.ts +32 -6
  332. package/src/runtime-mcp/plugin-mcp-name-filter.ts +62 -0
  333. package/src/runtime-mcp/project-host-values.ts +12 -8
  334. package/src/runtime-mcp/render.ts +16 -1
  335. package/src/runtime-mcp/tool-bridge.ts +194 -21
  336. package/src/runtime-mcp/transports/stdio.ts +2 -1
  337. package/src/sdk/broker/broker-exit.ts +10 -5
  338. package/src/sdk/broker/broker.ts +484 -85
  339. package/src/sdk/broker/daemon-entry.ts +338 -116
  340. package/src/sdk/broker/doctor-restart.ts +2 -1
  341. package/src/sdk/broker/ensure.ts +1214 -268
  342. package/src/sdk/broker/hop.ts +286 -0
  343. package/src/sdk/broker/lifecycle-ledger.ts +38 -11
  344. package/src/sdk/broker/lifecycle.ts +102 -6
  345. package/src/sdk/broker/lock-host-identity.ts +29 -0
  346. package/src/sdk/broker/session-index.ts +91 -16
  347. package/src/sdk/broker/transport.ts +17 -1
  348. package/src/sdk/bus/chat-command-policy.ts +16 -3
  349. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  350. package/src/sdk/bus/index.ts +460 -35
  351. package/src/sdk/bus/reconciliation-store.ts +201 -8
  352. package/src/sdk/bus/session-control.ts +2 -0
  353. package/src/sdk/bus/slack-daemon.ts +105 -9
  354. package/src/sdk/bus/telegram-daemon-contract.ts +9 -1
  355. package/src/sdk/bus/telegram-daemon.ts +10 -2
  356. package/src/sdk/bus/threaded-render.ts +20 -1
  357. package/src/sdk/cli/session-cli.ts +23 -6
  358. package/src/sdk/host/control/dispatch.ts +13 -1
  359. package/src/sdk/host/control/operations.ts +10 -1
  360. package/src/sdk/host/events.ts +67 -4
  361. package/src/sdk/host/host.ts +8 -2
  362. package/src/sdk/host/prompt-image-upload.ts +438 -0
  363. package/src/sdk/host/sdk-surface-parity.test.ts +15 -0
  364. package/src/sdk/host/session-runtime.test.ts +1737 -129
  365. package/src/sdk/host/session-runtime.ts +684 -83
  366. package/src/sdk/host/surface-policy.ts +4 -0
  367. package/src/sdk/host/websocket-transport.lifecycle.test.ts +90 -2
  368. package/src/sdk/host/websocket-transport.ts +19 -7
  369. package/src/sdk/lifecycle/service.ts +15 -1
  370. package/src/sdk/prompt-deadline-flush.ts +78 -5
  371. package/src/sdk/prompt-deadline-manager.ts +68 -14
  372. package/src/sdk/prompt-status.ts +6 -4
  373. package/src/sdk/protocol/adapter-validation.ts +81 -12
  374. package/src/sdk/protocol/operation-inventory.generated.json +261 -2
  375. package/src/sdk/protocol/operation-registry.ts +33 -3
  376. package/src/sdk/reconciliation-extensions.ts +1 -0
  377. package/src/sdk/session-list.ts +1 -0
  378. package/src/sdk/session.ts +237 -97
  379. package/src/session/agent-session.ts +2726 -479
  380. package/src/session/history-storage.ts +182 -12
  381. package/src/session/internal/managed-session-scope.ts +173 -57
  382. package/src/session/internal/managed-session-storage.ts +85 -12
  383. package/src/session/internal/native-publish-outcome.ts +5 -1
  384. package/src/session/messages.ts +47 -6
  385. package/src/session/session-manager.ts +326 -19
  386. package/src/session/startup-auth-config.ts +116 -8
  387. package/src/session/streaming-output.ts +262 -4
  388. package/src/session/terminal-abort.ts +14 -2
  389. package/src/session-import/redact.ts +53 -2
  390. package/src/session-import/service.ts +1 -0
  391. package/src/skill-state/workflow-mutation-guard.ts +2145 -206
  392. package/src/slash-commands/builtin-registry.ts +21 -6
  393. package/src/slash-commands/helpers/credential-switch.ts +10 -5
  394. package/src/slash-commands/helpers/mcp.ts +11 -3
  395. package/src/slash-commands/helpers/provider-auth-status.ts +456 -0
  396. package/src/ssh/ssh-executor.ts +9 -2
  397. package/src/stt/recorder.ts +26 -9
  398. package/src/system-prompt.ts +61 -16
  399. package/src/task/executor.ts +115 -20
  400. package/src/task/index.ts +389 -175
  401. package/src/task/role-body.ts +84 -0
  402. package/src/task/token-log-mode.test.ts +162 -0
  403. package/src/task/token-log.ts +91 -2
  404. package/src/tools/archive-reader.ts +650 -37
  405. package/src/tools/ask.ts +1 -0
  406. package/src/tools/ast-edit-write-path.ts +64 -0
  407. package/src/tools/ast-edit.ts +18 -1
  408. package/src/tools/ast-grep.ts +7 -2
  409. package/src/tools/atomic-file-write.ts +46 -15
  410. package/src/tools/auto-generated-guard.ts +15 -3
  411. package/src/tools/awaiting-input.ts +114 -0
  412. package/src/tools/bash-allowed-prefixes.ts +214 -6
  413. package/src/tools/bash-interactive.ts +14 -2
  414. package/src/tools/bash-skill-urls.ts +58 -5
  415. package/src/tools/bash.ts +113 -26
  416. package/src/tools/browser/tab-worker.ts +39 -13
  417. package/src/tools/browser.ts +17 -7
  418. package/src/tools/cron.ts +10 -1
  419. package/src/tools/debug.ts +16 -8
  420. package/src/tools/descriptors.test.ts +44 -0
  421. package/src/tools/descriptors.ts +50 -6
  422. package/src/tools/eval.ts +15 -0
  423. package/src/tools/fetch.ts +93 -4
  424. package/src/tools/gh.ts +27 -12
  425. package/src/tools/image-gen.ts +20 -6
  426. package/src/tools/index.ts +2 -0
  427. package/src/tools/irc.ts +5 -3
  428. package/src/tools/monitor.ts +9 -1
  429. package/src/tools/output-meta.ts +74 -5
  430. package/src/tools/python.ts +1 -1
  431. package/src/tools/read.ts +26 -37
  432. package/src/tools/recipe/runner.ts +23 -12
  433. package/src/tools/render-utils.ts +2 -2
  434. package/src/tools/report-issue.ts +184 -0
  435. package/src/tools/search.ts +3 -2
  436. package/src/tools/ssh.ts +274 -8
  437. package/src/tools/subagent-render.ts +114 -12
  438. package/src/tools/subagent.ts +118 -5
  439. package/src/tools/tmux-self-injection-guard.ts +3301 -122
  440. package/src/tools/todo-write.ts +41 -3
  441. package/src/tools/tool-catalog.generated.ts +32 -4
  442. package/src/tools/ultragoal-ask-guard.ts +13 -1
  443. package/src/tools/write.ts +42 -3
  444. package/src/tui/tree-list.ts +15 -1
  445. package/src/utils/commit-message-generator.ts +12 -4
  446. package/src/utils/external-editor.ts +21 -7
  447. package/src/utils/file-mentions.ts +75 -7
  448. package/src/utils/git.ts +38 -7
  449. package/src/utils/image-limits.ts +8 -0
  450. package/src/utils/image-loading.ts +1 -1
  451. package/src/utils/open.ts +73 -4
  452. package/src/utils/pasted-image-loading.ts +9 -8
  453. package/src/utils/prompt-suggestion.ts +5 -4
  454. package/src/utils/shell-snapshot.ts +9 -1
  455. package/src/utils/title-generator.ts +72 -22
  456. package/src/web/scrapers/index.ts +3 -1
  457. package/src/web/scrapers/types.ts +8 -0
  458. package/src/web/scrapers/youtube.ts +3 -2
  459. package/src/web/search/index.ts +2 -1
  460. package/src/web/search/providers/codex.ts +29 -32
  461. package/src/web/search/providers/insane.ts +91 -28
  462. package/src/web/search/providers/searxng.ts +62 -10
@@ -3,10 +3,15 @@ import type { BigIntStats } from "node:fs";
3
3
  import * as fs from "node:fs/promises";
4
4
  import * as os from "node:os";
5
5
  import path from "node:path";
6
- import type { NativeBrokerRestartIntent, NativeDirectoryTreeSnapshot } from "@gajae-code/natives";
6
+ import {
7
+ exactRemoveDirectoryTree,
8
+ type NativeBrokerRestartIntent,
9
+ type NativeDirectoryTreeSnapshot,
10
+ snapshotDirectoryTree,
11
+ } from "@gajae-code/natives";
7
12
  import { logger, resolveEquivalentPath } from "@gajae-code/utils";
8
13
  import packageJson from "../../../package.json" with { type: "json" };
9
- import { FileLockAcquireError } from "../../config/file-lock";
14
+ import { FileLockAcquireError, withFileLock } from "../../config/file-lock";
10
15
  import type { ModelProfileErrorDetails } from "../../config/model-profile-contract";
11
16
  import { planLaunchWorktree } from "../../gjc-runtime/launch-worktree";
12
17
  import { readExistingStateForMutation, withWorkflowStateLock } from "../../gjc-runtime/state-writer";
@@ -80,6 +85,7 @@ import {
80
85
  type LifecycleState,
81
86
  type TerminalReadBack,
82
87
  } from "./lifecycle-ledger";
88
+ import { brokerLockHostIdentity } from "./lock-host-identity";
83
89
  import {
84
90
  cancelManagedTasks,
85
91
  createManagedDomainBinding,
@@ -111,8 +117,15 @@ import {
111
117
  } from "./managed-task-dag";
112
118
  import { reconcileRunningManagedVerification, verifyManagedTaskAttempt } from "./managed-task-verification";
113
119
  import { createMasterCapabilityVerifier, readEndpoint } from "./master-capability";
120
+ import { isProcessIncarnation, observeProcessIncarnation } from "./process-incarnation";
114
121
  import { sdkInternalRuntimeImage } from "./runtime";
115
- import { type IndexedSession, isSessionAuthorityEligible, SessionIndex, type SessionList } from "./session-index";
122
+ import {
123
+ type IndexedSession,
124
+ isSessionAuthorityEligible,
125
+ SessionIndex,
126
+ type SessionList,
127
+ sessionIndexStartupLockOptions,
128
+ } from "./session-index";
116
129
  import {
117
130
  type ResolvedScopeV1,
118
131
  resolveScopeRequest,
@@ -152,6 +165,21 @@ export interface BrokerSettings {
152
165
  masterCapabilityVerifier?: MasterCapabilityVerifier;
153
166
  /** Grace before an orphaned spawn child closes; schema-bounded in production. */
154
167
  masterOrphanGraceMs?: number;
168
+ /**
169
+ * Grace before a broker nobody is left to use exits on its own. The clock only
170
+ * runs while the index proves no live session host and no open spawn authority,
171
+ * so a broker whose client died without closing exits instead of holding its
172
+ * port, its publication, and its lock for the life of the machine.
173
+ */
174
+ abandonedGraceMs?: number;
175
+ /**
176
+ * Whether this broker may end itself once nobody is left to use it. Only the
177
+ * detached autostart entry point enables this: an operator-supervised
178
+ * `gjc sdk broker run` service owns its own lifetime, and its supervisor reads an
179
+ * owned-root completion as a clean exit, so self-reaping would quietly retire a
180
+ * service that was meant to keep serving.
181
+ */
182
+ reapWhenAbandoned?: boolean;
155
183
  /** Host model resolver override for lifecycle tests and embedders. */
156
184
  resolveModelPin?: SdkHostModelResolver;
157
185
  /**
@@ -189,6 +217,8 @@ type ResolvedBrokerSettings = {
189
217
  spawnSubstrateProvider?: SpawnSubstrateProvider;
190
218
  spawnPromptLayer?: SpawnPromptLayer;
191
219
  masterOrphanGraceMs: number;
220
+ abandonedGraceMs: number;
221
+ reapWhenAbandoned: boolean;
192
222
  restartRequestId?: string;
193
223
  };
194
224
  export function resolveBrokerPackageGeneration(): string {
@@ -214,6 +244,7 @@ function modelResolutionCwd(input: Record<string, unknown>): string | undefined
214
244
 
215
245
  export type BrokerErrorCode =
216
246
  | "idempotency_conflict"
247
+ | "legacy_lifecycle_ambiguous"
217
248
  | "terminal_uncertain"
218
249
  | "broker_restarting"
219
250
  | "unavailable"
@@ -610,6 +641,7 @@ const SPAWN_HOST_REGISTRATION_TIMEOUT_MS = 10_000;
610
641
  const SPAWN_HOST_REGISTRATION_POLL_MS = 50;
611
642
  const SPAWN_PROMPT_EXCHANGE_TIMEOUT_MS = 10_000;
612
643
  const MASTER_ORPHAN_GRACE_DEFAULT_MS = 120_000;
644
+ const BROKER_ABANDONED_GRACE_DEFAULT_MS = 120_000;
613
645
  const MANAGED_CLOSE_WAIT_MS = 10_000;
614
646
 
615
647
  function settlesWithin(promise: Promise<unknown>, timeoutMs: number): Promise<boolean> {
@@ -1088,6 +1120,13 @@ function sameSessionControlAuthority(
1088
1120
  );
1089
1121
  }
1090
1122
 
1123
+ const SESSION_TARGETED_LIFECYCLE_OPERATIONS: ReadonlySet<string> = new Set([
1124
+ "session.close",
1125
+ "session.resume",
1126
+ "session.delete",
1127
+ "session.reconcile_uncertain",
1128
+ ]);
1129
+
1091
1130
  function lifecycleTarget(operation: string, input: Record<string, unknown>): unknown {
1092
1131
  const target = input.target as Record<string, unknown> | undefined;
1093
1132
  const string = (...values: unknown[]): string | undefined =>
@@ -1163,10 +1202,29 @@ const BROKER_LOCK_RETRY_MS = 10;
1163
1202
  type BrokerLockSnapshot = {
1164
1203
  ownerId?: string;
1165
1204
  pid: number;
1205
+ incarnation?: string;
1206
+ ownerHostId?: string;
1166
1207
  identity: string;
1167
1208
  lockIdentity: string;
1168
1209
  };
1169
1210
 
1211
+ function isBrokerLockOwnerAlive(
1212
+ snapshot: Pick<BrokerLockSnapshot, "pid" | "incarnation" | "ownerHostId">,
1213
+ currentHostId: string | undefined,
1214
+ pidAlive: (pid: number) => boolean = isPidAlive,
1215
+ readProcessIncarnation: (pid: number) => string | undefined = brokerProcessIncarnation,
1216
+ ): boolean {
1217
+ if (snapshot.pid <= 0) return false;
1218
+ // Local PID evidence is meaningful only when the owner record identifies this host.
1219
+ // Foreign and legacy host-unqualified owners are never reclaimed from this process table.
1220
+ if (snapshot.ownerHostId === undefined || snapshot.ownerHostId !== currentHostId) return true;
1221
+ if (!pidAlive(snapshot.pid)) return false;
1222
+ // A live PID is retained unless its recorded incarnation proves reuse.
1223
+ if (!snapshot.incarnation) return true;
1224
+ const incarnation = readProcessIncarnation(snapshot.pid);
1225
+ return !incarnation || incarnation === snapshot.incarnation;
1226
+ }
1227
+
1170
1228
  /** Tombstone prefix used by {@link Broker.reclaimStaleLock} when a dead owner's lock is renamed aside. */
1171
1229
  export const BROKER_LOCK_TOMBSTONE_PREFIX = ".broker.lock.stale-";
1172
1230
 
@@ -1214,11 +1272,12 @@ function isBrokerLockArtifactName(name: string): boolean {
1214
1272
  * Decide whether one candidate directory is provably abandoned.
1215
1273
  *
1216
1274
  * Fail-closed by construction: every branch that cannot prove abandonment
1217
- * returns a retention reason. A tombstone with an owner record is only
1218
- * abandoned when that record names a dead PID. A missing record is abandoned
1219
- * only when the aged tombstone is empty; non-empty tombstones remain retained
1220
- * as ambiguous evidence. Backup directories carry no owner contract, so an
1221
- * absent record there is not ambiguity and age alone governs.
1275
+ * returns a retention reason. A tombstone with an owner record is abandoned
1276
+ * only when its PID is dead or validated incarnation evidence proves PID reuse;
1277
+ * missing or unavailable evidence remains retained. A missing record is
1278
+ * abandoned only when the aged tombstone is empty; non-empty tombstones remain
1279
+ * retained as ambiguous evidence. Backup directories carry no owner contract,
1280
+ * so an absent record there is not ambiguity and age alone governs.
1222
1281
  */
1223
1282
  async function classifyBrokerLockArtifact(
1224
1283
  directory: string,
@@ -1226,6 +1285,8 @@ async function classifyBrokerLockArtifact(
1226
1285
  now: number,
1227
1286
  graceMs: number,
1228
1287
  pidAlive: (pid: number) => boolean,
1288
+ readProcessIncarnation: (pid: number) => string | undefined,
1289
+ currentHostId: string | undefined,
1229
1290
  ): Promise<BrokerLockArtifactRetentionReason | "abandoned"> {
1230
1291
  const target = path.join(directory, name);
1231
1292
  // lstat, never stat: a symlink pointing at live state must never be followed
@@ -1248,14 +1309,26 @@ async function classifyBrokerLockArtifact(
1248
1309
  return "owner-record-unreadable";
1249
1310
  }
1250
1311
  }
1251
- let pid: unknown;
1312
+ let owner: { pid?: unknown; incarnation?: unknown; ownerHostId?: unknown };
1252
1313
  try {
1253
- pid = (JSON.parse(raw) as { pid?: unknown }).pid;
1314
+ owner = JSON.parse(raw) as { pid?: unknown; incarnation?: unknown; ownerHostId?: unknown };
1254
1315
  } catch {
1255
1316
  return "owner-record-unreadable";
1256
1317
  }
1257
- if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) return "owner-record-unreadable";
1258
- return pidAlive(pid) ? "owner-alive" : "abandoned";
1318
+ if (typeof owner.pid !== "number" || !Number.isInteger(owner.pid) || owner.pid <= 0)
1319
+ return "owner-record-unreadable";
1320
+ return isBrokerLockOwnerAlive(
1321
+ {
1322
+ pid: owner.pid,
1323
+ incarnation: isProcessIncarnation(owner.incarnation) ? owner.incarnation : undefined,
1324
+ ownerHostId: typeof owner.ownerHostId === "string" ? owner.ownerHostId : undefined,
1325
+ },
1326
+ currentHostId,
1327
+ pidAlive,
1328
+ readProcessIncarnation,
1329
+ )
1330
+ ? "owner-alive"
1331
+ : "abandoned";
1259
1332
  }
1260
1333
 
1261
1334
  /**
@@ -1274,11 +1347,18 @@ export async function reapStaleBrokerLockArtifacts(input: {
1274
1347
  now?: number;
1275
1348
  graceMs?: number;
1276
1349
  pidAlive?: (pid: number) => boolean;
1350
+ processIncarnation?: (pid: number) => string | undefined;
1277
1351
  }): Promise<BrokerLockArtifactReapResult> {
1278
1352
  const directory = path.join(input.agentDir, "sdk");
1279
1353
  const now = input.now ?? Date.now();
1280
1354
  const graceMs = input.graceMs ?? BROKER_LOCK_ARTIFACT_GRACE_MS;
1281
1355
  const pidAlive = input.pidAlive ?? isPidAlive;
1356
+ const readProcessIncarnation = input.processIncarnation ?? brokerProcessIncarnation;
1357
+ // Without this host's identity no host-qualified owner can be matched, so all of them are retained.
1358
+ const currentHostId = await brokerLockHostIdentity(input.agentDir).then(
1359
+ identity => identity.ownerHostId,
1360
+ () => undefined,
1361
+ );
1282
1362
  const removed: string[] = [];
1283
1363
  const retained: BrokerLockArtifactRetention[] = [];
1284
1364
  const retainedByReason = new Map<BrokerLockArtifactRetentionReason, number>();
@@ -1295,7 +1375,15 @@ export async function reapStaleBrokerLockArtifacts(input: {
1295
1375
  const target = path.join(directory, name);
1296
1376
  let verdict: BrokerLockArtifactRetentionReason | "abandoned";
1297
1377
  try {
1298
- verdict = await classifyBrokerLockArtifact(directory, name, now, graceMs, pidAlive);
1378
+ verdict = await classifyBrokerLockArtifact(
1379
+ directory,
1380
+ name,
1381
+ now,
1382
+ graceMs,
1383
+ pidAlive,
1384
+ readProcessIncarnation,
1385
+ currentHostId,
1386
+ );
1299
1387
  } catch (error) {
1300
1388
  // A vanished candidate needs no decision; anything else is ambiguous.
1301
1389
  if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
@@ -1528,7 +1616,12 @@ const terminalPersistenceHooksForTest = new WeakMap<Broker, () => void>();
1528
1616
  const ambiguityGraceOverridesForTest = new WeakMap<Broker, number>();
1529
1617
  const publicationObservationOverridesForTest = new WeakMap<Broker, BrokerPublicationObservation>();
1530
1618
  const lockArtifactGraceOverridesForTest = new WeakMap<Broker, number>();
1619
+ const brokerLockReclaimHooksForTest = new WeakMap<
1620
+ Broker,
1621
+ { beforeQuarantine?: () => Promise<void>; onContended?: () => void }
1622
+ >();
1531
1623
  const livenessGraceOverridesForTest = new WeakMap<Broker, number>();
1624
+ const abandonedGraceOverridesForTest = new WeakMap<Broker, number>();
1532
1625
  const heartbeatStallOverridesForTest = new WeakMap<Broker, PromiseWithResolvers<void>>();
1533
1626
  const managedCloseWaitOverridesForTest = new WeakMap<Broker, number>();
1534
1627
 
@@ -1539,6 +1632,8 @@ export class Broker {
1539
1632
  discovery: BrokerDiscovery | null = null;
1540
1633
  #lock: string;
1541
1634
  #owner = randomBytes(12).toString("hex");
1635
+ #ownedLockIdentity: string | undefined;
1636
+ #ownedLockIncarnation: string | undefined;
1542
1637
  /** Public publication-incarnation id, fixed at startup; never the endpoint generation. */
1543
1638
  #diagnosticGeneration = randomBytes(16).toString("hex");
1544
1639
  /** Trusted build snapshot captured at startup; unknown buildId stays null. */
@@ -1569,6 +1664,8 @@ export class Broker {
1569
1664
  #publishedAt: bigint | null = null;
1570
1665
  #startedAt: bigint | null = null;
1571
1666
  #watchInFlight = false;
1667
+ #abandonedAt: number | null = null;
1668
+ #abandonedReapInFlight = false;
1572
1669
  #checkpointInFlight = false;
1573
1670
  #stopping = false;
1574
1671
  #transport: BrokerTransport | null = null;
@@ -1608,6 +1705,8 @@ export class Broker {
1608
1705
  spawnSubstrateProvider: settings.spawnSubstrateProvider,
1609
1706
  spawnPromptLayer: settings.spawnPromptLayer,
1610
1707
  masterOrphanGraceMs: settings.masterOrphanGraceMs ?? MASTER_ORPHAN_GRACE_DEFAULT_MS,
1708
+ abandonedGraceMs: settings.abandonedGraceMs ?? BROKER_ABANDONED_GRACE_DEFAULT_MS,
1709
+ reapWhenAbandoned: settings.reapWhenAbandoned === true,
1611
1710
  ...(settings.restartRequestId === undefined ? {} : { restartRequestId: settings.restartRequestId }),
1612
1711
  };
1613
1712
  this.index = new SessionIndex(settings.agentDir);
@@ -3581,6 +3680,96 @@ export class Broker {
3581
3680
  await this.#reapSpawnOrphans();
3582
3681
  }
3583
3682
 
3683
+ /**
3684
+ * Grace before an idle broker exits. The clock starts only after the index has
3685
+ * already stopped proving any host live, so this grace is time on top of that
3686
+ * freshness window, never a substitute for it.
3687
+ */
3688
+ #abandonedGraceMs(): number {
3689
+ return abandonedGraceOverridesForTest.get(this) ?? this.settings.abandonedGraceMs;
3690
+ }
3691
+
3692
+ /**
3693
+ * What the index proves about the hosts using this agent directory. Liveness is
3694
+ * judged on read -- fresh checkpoint, live pid, matching incarnation -- so a host
3695
+ * that died without closing stops proving it without help. A row that is not live
3696
+ * is absence only once the OS confirms the process is gone: an inconclusive
3697
+ * reading is never death proof on any platform, and a pid that still exists but
3698
+ * cannot be matched to the recorded incarnation leaves the host undecided.
3699
+ */
3700
+ async #sessionHostLiveness(): Promise<"live" | "absent" | "unknown"> {
3701
+ try {
3702
+ await this.index.refreshIfChanged();
3703
+ } catch (error) {
3704
+ logger.warn(`sdk broker: abandonment probe could not read the session index: ${String(error)}`);
3705
+ return "unknown";
3706
+ }
3707
+ for (const row of this.index.listSessionIdentities()) {
3708
+ if (row.endpointGeneration <= 0) continue;
3709
+ if (row.live) return "live";
3710
+ // The tri-state observer keeps positive absence separate from every
3711
+ // inconclusive outcome, which is the difference between reaping a broker and
3712
+ // killing one out from under a host this platform simply cannot confirm.
3713
+ const observed = observeProcessIncarnation(row.pid);
3714
+ if (observed.status === "unknown") return "unknown";
3715
+ if (observed.status === "absent") continue;
3716
+ const recorded = row.hostIncarnation ?? row.processIncarnation;
3717
+ if (recorded === undefined) return "unknown";
3718
+ if (observed.incarnation === recorded) return "live";
3719
+ // The pid names another process now: the host that registered it is gone.
3720
+ }
3721
+ return "absent";
3722
+ }
3723
+
3724
+ /** Whether any client currently holds a transport connection to this broker. */
3725
+ #hasAttachedClient(): boolean {
3726
+ return (this.#transport?.clientCount ?? 0) > 0;
3727
+ }
3728
+
3729
+ /** Whether this broker still owns a spawn authority it must keep serving. */
3730
+ #hasOpenSpawnAuthority(): boolean {
3731
+ const store = this.#spawnAuthority;
3732
+ if (!store) return false;
3733
+ for (const claim of store.claims()) {
3734
+ const authority = store.authority(claim.lifecycleIdentity);
3735
+ if (authority && (authority.closeState === "active" || authority.closeState === "close_requested"))
3736
+ return true;
3737
+ }
3738
+ return false;
3739
+ }
3740
+
3741
+ /**
3742
+ * Ends a broker nobody is left to use. A client that dies without closing leaves
3743
+ * no live session host behind, and nothing else reaps this process: without this
3744
+ * pass it would hold its port, its publication, and its lock until the machine
3745
+ * reboots. The clock only runs while absence is confirmed and no client holds a
3746
+ * connection, and any live host, attached client, open authority, or undecided
3747
+ * reading disarms it, so a slow reattach never races the grace. Brokers an
3748
+ * operator supervises never reap themselves at all.
3749
+ */
3750
+ async #reapSelfIfAbandoned(): Promise<void> {
3751
+ if (!this.settings.reapWhenAbandoned) return;
3752
+ if (this.#stopping || this.#completionTask !== null || this.#fenceReason !== null) return;
3753
+ if (this.#abandonedReapInFlight) return;
3754
+ this.#abandonedReapInFlight = true;
3755
+ try {
3756
+ const liveness = await this.#sessionHostLiveness();
3757
+ const now = Date.now();
3758
+ if (liveness !== "absent" || this.#hasAttachedClient() || this.#hasOpenSpawnAuthority()) {
3759
+ this.#abandonedAt = null;
3760
+ return;
3761
+ }
3762
+ if (this.#abandonedAt === null) {
3763
+ this.#abandonedAt = now;
3764
+ return;
3765
+ }
3766
+ if (now - this.#abandonedAt < this.#abandonedGraceMs()) return;
3767
+ await this.#complete(this.#provenOwnedRoot() ? "owned-root" : "lost-root", "abandoned");
3768
+ } finally {
3769
+ this.#abandonedReapInFlight = false;
3770
+ }
3771
+ }
3772
+
3584
3773
  async #reapSpawnOrphans(): Promise<void> {
3585
3774
  if (this.#spawnReapInFlight || this.#stopping) return;
3586
3775
  this.#spawnReapInFlight = true;
@@ -3663,7 +3852,12 @@ export class Broker {
3663
3852
  }
3664
3853
  async #lockSnapshot(raw: string, lockIdentity: string): Promise<BrokerLockSnapshot> {
3665
3854
  try {
3666
- const lock = JSON.parse(raw) as { ownerId?: unknown; pid?: unknown };
3855
+ const lock = JSON.parse(raw) as {
3856
+ ownerId?: unknown;
3857
+ pid?: unknown;
3858
+ incarnation?: unknown;
3859
+ ownerHostId?: unknown;
3860
+ };
3667
3861
  if (
3668
3862
  typeof lock.ownerId === "string" &&
3669
3863
  lock.ownerId.length > 0 &&
@@ -3671,7 +3865,14 @@ export class Broker {
3671
3865
  Number.isInteger(lock.pid) &&
3672
3866
  lock.pid > 0
3673
3867
  )
3674
- return { ownerId: lock.ownerId, pid: lock.pid, identity: `owner:${lock.ownerId}`, lockIdentity };
3868
+ return {
3869
+ ownerId: lock.ownerId,
3870
+ pid: lock.pid,
3871
+ incarnation: isProcessIncarnation(lock.incarnation) ? lock.incarnation : undefined,
3872
+ ownerHostId: typeof lock.ownerHostId === "string" ? lock.ownerHostId : undefined,
3873
+ identity: `owner:${lock.ownerId}`,
3874
+ lockIdentity,
3875
+ };
3675
3876
  } catch {}
3676
3877
  return { pid: 0, identity: `contents:${createHash("sha256").update(raw).digest("hex")}`, lockIdentity };
3677
3878
  }
@@ -3703,35 +3904,103 @@ export class Broker {
3703
3904
  }
3704
3905
  return this.#lockSnapshot(raw, lockIdentity);
3705
3906
  }
3706
- async #createLock(): Promise<void> {
3907
+ async #withLockReclaimGuard<T>(operation: () => Promise<T>): Promise<T> {
3908
+ const hostIdentity = await brokerLockHostIdentity(this.settings.agentDir);
3909
+ return withFileLock(`${this.#lock}.reclaim`, operation, {
3910
+ ...hostIdentity,
3911
+ onContended: () => brokerLockReclaimHooksForTest.get(this)?.onContended?.(),
3912
+ });
3913
+ }
3914
+ async #hasCanonicalLockOwnership(): Promise<boolean> {
3915
+ if (!this.#ownedLockIdentity || !this.#ownedLockIncarnation) return false;
3916
+ try {
3917
+ const current = await this.#readLock();
3918
+ return (
3919
+ current?.identity === `owner:${this.#owner}` &&
3920
+ current.lockIdentity === this.#ownedLockIdentity &&
3921
+ current.pid === process.pid &&
3922
+ current.incarnation === this.#ownedLockIncarnation
3923
+ );
3924
+ } catch {
3925
+ return false;
3926
+ }
3927
+ }
3928
+ #removeCreatedLockGeneration(
3929
+ lockIdentity: { dev: string; ino: string },
3930
+ parentIdentity: { dev: bigint; ino: bigint },
3931
+ ownerHash?: string,
3932
+ ): void {
3933
+ const captured = snapshotDirectoryTree(this.#lock);
3934
+ const snapshot = captured.snapshot;
3935
+ const root = snapshot?.entries.find(entry => entry.relativePath === "");
3936
+ if (
3937
+ !captured.ok ||
3938
+ !snapshot ||
3939
+ root?.kind !== "directory" ||
3940
+ snapshot.rootDev !== lockIdentity.dev ||
3941
+ snapshot.rootIno !== lockIdentity.ino
3942
+ )
3943
+ return;
3944
+
3945
+ const owner = snapshot.entries.find(entry => entry.relativePath === BROKER_LOCK_RECORD);
3946
+ const exactOwner =
3947
+ ownerHash !== undefined &&
3948
+ snapshot.entries.length === 2 &&
3949
+ owner?.kind === "file" &&
3950
+ owner.sha256 === ownerHash;
3951
+ if (!exactOwner && snapshot.entries.length !== 1) return;
3952
+
3953
+ exactRemoveDirectoryTree(this.#lock, snapshot, parentIdentity);
3954
+ }
3955
+ async #createLockInsideGuard(): Promise<void> {
3956
+ const rootDigest = await canonicalServiceRootDigest(this.settings.agentDir);
3957
+ const hostIdentity = await brokerLockHostIdentity(this.settings.agentDir);
3958
+ const parent = await fs.lstat(path.dirname(this.#lock), { bigint: true });
3707
3959
  await fs.mkdir(this.#lock, { mode: 0o700 });
3960
+ let createdLockIdentity: { dev: string; ino: string } | undefined;
3961
+ let createdOwnerHash: string | undefined;
3708
3962
  try {
3963
+ const lockStat = await fs.lstat(this.#lock, { bigint: true });
3964
+ if (!lockStat.isDirectory()) throw new Error("SDK broker lock is not a directory.");
3965
+ createdLockIdentity = { dev: String(lockStat.dev), ino: String(lockStat.ino) };
3709
3966
  const incarnation = brokerProcessIncarnation(process.pid);
3710
- // A minimal, non-secret receipt delta for D9 stale-artifact detach
3711
- // (sequenced follow-up): the OS incarnation and a digest of the exact
3712
- // resolved root this lock authorizes, so a later positive-death check can
3713
- // cross-bind this owner record against the discovery record it authored
3714
- // without re-deriving anything from file content or adding new identity.
3715
- const rootDigest = await canonicalServiceRootDigest(this.settings.agentDir);
3716
- await fs.writeFile(
3717
- this.#lockRecordPath(),
3718
- JSON.stringify({
3719
- version: 1,
3720
- ownerId: this.#owner,
3721
- pid: process.pid,
3722
- acquiredAt: Date.now(),
3723
- ...(incarnation ? { incarnation } : {}),
3724
- rootDigest,
3725
- }),
3726
- { flag: "wx", mode: 0o600 },
3727
- );
3728
- } catch (e) {
3967
+ if (!incarnation) throw new Error("Broker process incarnation is unavailable.");
3968
+ const ownerRecord = JSON.stringify({
3969
+ version: 1,
3970
+ ownerId: this.#owner,
3971
+ pid: process.pid,
3972
+ acquiredAt: Date.now(),
3973
+ incarnation,
3974
+ ownerHostId: hostIdentity.ownerHostId,
3975
+ rootDigest,
3976
+ });
3977
+ createdOwnerHash = createHash("sha256").update(ownerRecord).digest("hex");
3978
+ await fs.writeFile(this.#lockRecordPath(), ownerRecord, { flag: "wx", mode: 0o600 });
3979
+ const current = await this.#readLock();
3980
+ if (
3981
+ !current ||
3982
+ current.ownerId !== this.#owner ||
3983
+ current.pid !== process.pid ||
3984
+ current.incarnation !== incarnation
3985
+ )
3986
+ throw new Error("SDK broker lock ownership changed during startup.");
3987
+ this.#ownedLockIdentity = current.lockIdentity;
3988
+ this.#ownedLockIncarnation = incarnation;
3989
+ } catch (error) {
3729
3990
  try {
3730
- await fs.rmdir(this.#lock);
3991
+ if (createdLockIdentity)
3992
+ this.#removeCreatedLockGeneration(
3993
+ createdLockIdentity,
3994
+ { dev: parent.dev, ino: parent.ino },
3995
+ createdOwnerHash,
3996
+ );
3731
3997
  } catch {}
3732
- throw e;
3998
+ throw error;
3733
3999
  }
3734
4000
  }
4001
+ async #createLock(): Promise<void> {
4002
+ await this.#withLockReclaimGuard(() => this.#createLockInsideGuard());
4003
+ }
3735
4004
  async #waitForBrokerDiscovery(): Promise<BrokerDiscovery | null> {
3736
4005
  const deadline = Date.now() + BROKER_LOCK_STARTUP_WAIT_MS;
3737
4006
  while (Date.now() < deadline) {
@@ -3741,50 +4010,63 @@ export class Broker {
3741
4010
  }
3742
4011
  return readBrokerDiscovery(this.settings.agentDir, this.settings.heartbeatTtlMs);
3743
4012
  }
3744
- async #reclaimStaleLock(snapshot: BrokerLockSnapshot): Promise<void> {
3745
- const current = await this.#readLock();
3746
- if (
3747
- !current ||
3748
- current.identity !== snapshot.identity ||
3749
- current.lockIdentity !== snapshot.lockIdentity ||
3750
- (current.pid > 0 && isPidAlive(current.pid))
3751
- )
3752
- return;
3753
-
3754
- // Give every reclaimed generation a fresh quarantine name. Older brokers
3755
- // used only the lock inode hash; if that tombstone survived a crash, the
3756
- // deterministic name collided forever and left the dead canonical lock in
3757
- // place. The random suffix preserves no-replace rename semantics without
3758
- // letting retained cleanup debris block takeover.
3759
- for (let attempt = 0; attempt < 8; attempt++) {
3760
- const tombstone = path.join(
3761
- path.dirname(this.#lock),
3762
- `.broker.lock.stale-${createHash("sha256").update(snapshot.lockIdentity).digest("hex")}-${randomBytes(8).toString("hex")}`,
3763
- );
3764
- try {
3765
- await fs.rename(this.#lock, tombstone);
3766
- return;
3767
- } catch (e) {
3768
- const code = (e as NodeJS.ErrnoException).code;
3769
- if (code === "EEXIST" || code === "ENOTEMPTY") continue;
3770
- if (["ENOENT", "EISDIR", "ENOTDIR"].includes(code ?? "")) return;
3771
- if (code === "EPERM") {
3772
- try {
3773
- await fs.lstat(tombstone);
3774
- continue;
3775
- } catch (statError) {
3776
- if ((statError as NodeJS.ErrnoException).code !== "ENOENT") throw statError;
4013
+ async #reclaimStaleLock(snapshot: BrokerLockSnapshot): Promise<boolean> {
4014
+ return await this.#withLockReclaimGuard(async () => {
4015
+ const current = await this.#readLock();
4016
+ const hostIdentity = await brokerLockHostIdentity(this.settings.agentDir);
4017
+ if (
4018
+ !current ||
4019
+ current.identity !== snapshot.identity ||
4020
+ current.lockIdentity !== snapshot.lockIdentity ||
4021
+ isBrokerLockOwnerAlive(current, hostIdentity.ownerHostId)
4022
+ )
4023
+ return false;
4024
+ await brokerLockReclaimHooksForTest.get(this)?.beforeQuarantine?.();
4025
+
4026
+ // Keep validation, quarantine, and successor lock publication under one
4027
+ // cross-process fence so a stale snapshot can never move a newer lock.
4028
+ for (let attempt = 0; attempt < 8; attempt++) {
4029
+ const tombstone = path.join(
4030
+ path.dirname(this.#lock),
4031
+ `.broker.lock.stale-${createHash("sha256").update(snapshot.lockIdentity).digest("hex")}-${randomBytes(8).toString("hex")}`,
4032
+ );
4033
+ try {
4034
+ await fs.rename(this.#lock, tombstone);
4035
+ } catch (error) {
4036
+ const code = (error as NodeJS.ErrnoException).code;
4037
+ if (code === "EEXIST" || code === "ENOTEMPTY") continue;
4038
+ if (["ENOENT", "EISDIR", "ENOTDIR"].includes(code ?? "")) return false;
4039
+ if (code === "EPERM") {
4040
+ try {
4041
+ await fs.lstat(tombstone);
4042
+ continue;
4043
+ } catch (statError) {
4044
+ if ((statError as NodeJS.ErrnoException).code !== "ENOENT") throw statError;
4045
+ }
3777
4046
  }
4047
+ throw error;
4048
+ }
4049
+ try {
4050
+ await this.#createLockInsideGuard();
4051
+ return true;
4052
+ } catch (error) {
4053
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") return false;
4054
+ throw error;
3778
4055
  }
3779
- throw e;
3780
4056
  }
3781
- }
3782
- throw new Error(`Broker lock quarantine namespace is saturated for ${this.#lock}`);
4057
+ throw new Error(`Broker lock quarantine namespace is saturated for ${this.#lock}`);
4058
+ });
3783
4059
  }
3784
4060
  async #releaseOwnedLock(): Promise<void> {
3785
4061
  try {
3786
4062
  const lock = await this.#readLock();
3787
- if (lock?.ownerId !== this.#owner) return;
4063
+ if (
4064
+ !lock ||
4065
+ lock.ownerId !== this.#owner ||
4066
+ lock.lockIdentity !== this.#ownedLockIdentity ||
4067
+ lock.incarnation !== this.#ownedLockIncarnation
4068
+ )
4069
+ return;
3788
4070
  await fs.unlink(this.#lockRecordPath());
3789
4071
  await fs.rmdir(this.#lock);
3790
4072
  } catch (error) {
@@ -3833,8 +4115,15 @@ export class Broker {
3833
4115
  this.#ambiguousAt = null;
3834
4116
  this.#publishedAt = null;
3835
4117
  this.#startedAt = process.hrtime.bigint();
4118
+ this.#ownedLockIdentity = undefined;
4119
+ this.#ownedLockIncarnation = undefined;
3836
4120
  this.#watchInFlight = false;
3837
4121
  this.#checkpointInFlight = false;
4122
+ // A restarted instance is a new generation: it holds none of the state the
4123
+ // previous one stopped with, so the abandonment clock it armed (and any reap
4124
+ // still unwinding) must not survive into the generation clients reconnect to.
4125
+ this.#abandonedAt = null;
4126
+ this.#abandonedReapInFlight = false;
3838
4127
  this.#throwIfStartupAborted();
3839
4128
  await Promise.all([this.ledger.assertSupportedStateVersions(), readBrokerDiscovery(this.settings.agentDir)]);
3840
4129
  this.#throwIfStartupAborted();
@@ -3861,7 +4150,8 @@ export class Broker {
3861
4150
  }
3862
4151
  const snapshot = await this.#readLock();
3863
4152
  if (!snapshot) continue;
3864
- if (snapshot.pid > 0 && isPidAlive(snapshot.pid)) {
4153
+ const hostIdentity = await brokerLockHostIdentity(this.settings.agentDir);
4154
+ if (isBrokerLockOwnerAlive(snapshot, hostIdentity.ownerHostId)) {
3865
4155
  const starting = await this.#waitForBrokerDiscovery();
3866
4156
  if (starting) {
3867
4157
  logger.info(
@@ -3872,22 +4162,59 @@ export class Broker {
3872
4162
  return starting;
3873
4163
  }
3874
4164
  const current = await this.#readLock();
3875
- if (current && current.identity === snapshot.identity && current.pid > 0 && isPidAlive(current.pid)) {
4165
+ if (
4166
+ current &&
4167
+ current.identity === snapshot.identity &&
4168
+ isBrokerLockOwnerAlive(current, hostIdentity.ownerHostId)
4169
+ ) {
4170
+ const hostUnqualified = current.ownerHostId === undefined;
4171
+ const foreignHostOwner = !hostUnqualified && current.ownerHostId !== hostIdentity.ownerHostId;
4172
+ let refusal: string;
4173
+ let errorMessage: string;
4174
+ if (hostUnqualified) {
4175
+ refusal = `owner pid ${current.pid} has no host identity; automatic recovery is unsafe for shared agent directories. Stop every broker using this agent directory on all hosts, then follow https://github.com/Yeachan-Heo/gajae-code/blob/dev/docs/sdk.md#recovering-legacy-broker-locks`;
4176
+ errorMessage = `Broker lock owner is host-unqualified (pid ${current.pid}); refusing automatic recovery because its host cannot be verified. Stop every broker using this agent directory on all hosts and follow https://github.com/Yeachan-Heo/gajae-code/blob/dev/docs/sdk.md#recovering-legacy-broker-locks`;
4177
+ } else if (foreignHostOwner) {
4178
+ refusal = `owner pid ${current.pid} is attributed to another host; its liveness cannot be verified here`;
4179
+ errorMessage = `Broker lock owner is attributed to a different host (pid ${current.pid}); refusing automatic recovery because its liveness cannot be verified from this host.`;
4180
+ } else {
4181
+ refusal = `live pid ${current.pid} has published no discovery record`;
4182
+ errorMessage = `Broker lock is held by a live owner (pid ${current.pid})`;
4183
+ }
3876
4184
  logger.warn(
3877
- `sdk broker: lock contention, refusing to start because ${this.#lock} is held by live pid ${current.pid} that published no discovery record`,
4185
+ `sdk broker: lock contention, refusing to start because ${this.#lock} is held by ${refusal}`,
3878
4186
  );
3879
- throw new Error(`Broker lock is held by a live owner (pid ${current.pid})`);
4187
+ throw new Error(errorMessage);
3880
4188
  }
3881
4189
  continue;
3882
4190
  }
3883
- await this.#reclaimStaleLock(snapshot);
4191
+ if (await this.#reclaimStaleLock(snapshot)) break;
3884
4192
  }
3885
4193
  this.#throwIfStartupAborted();
3886
4194
  // Only the lock holder reaps, so concurrent brokers cannot race the removal.
3887
4195
  await this.#reapLockArtifacts();
3888
4196
  try {
3889
4197
  this.#throwIfStartupAborted();
3890
- await this.index.open();
4198
+ // Recheck under the broker ownership lock: a restart can be committed while
4199
+ // an ordinary startup is waiting to acquire this lock.
4200
+ const restartIntent = await readBrokerRestartIntent(this.settings.agentDir);
4201
+ const activeCommittedIntent =
4202
+ restartIntent?.phase === "committed" && restartIntent.expiresAt > Date.now() ? restartIntent : undefined;
4203
+ if (
4204
+ this.settings.restartRequestId === undefined
4205
+ ? activeCommittedIntent !== undefined
4206
+ : activeCommittedIntent?.requestId !== this.settings.restartRequestId
4207
+ )
4208
+ throw new Error("A committed SDK broker restart is active; startup requires its exact request ID.");
4209
+ await this.index.open(
4210
+ this.#startupCheckpointDeadline === undefined
4211
+ ? undefined
4212
+ : sessionIndexStartupLockOptions(
4213
+ this.#startupCheckpointDeadline,
4214
+ this.#startupAbortSignal,
4215
+ this.settings.restartRequestId === undefined ? "ordinary" : "authorized-successor",
4216
+ ),
4217
+ );
3891
4218
  await this.ledger.open();
3892
4219
  const brokerIdentityKey = await getBrokerIdentityKey(this.settings.agentDir);
3893
4220
  this.#spawnAuthority = new SpawnAuthorityStore(this.settings.agentDir, brokerIdentityKey);
@@ -3950,6 +4277,8 @@ export class Broker {
3950
4277
  await this.#startupPrePublicationTestHook?.();
3951
4278
  if (this.#startupPrePublicationDelayMs > 0) await Bun.sleep(this.#startupPrePublicationDelayMs);
3952
4279
  this.#throwIfStartupAborted();
4280
+ if (!(await this.#hasCanonicalLockOwnership()))
4281
+ throw new Error("SDK broker lock ownership changed before discovery publication.");
3953
4282
  this.#publication = await publishBrokerDiscovery(
3954
4283
  this.settings.agentDir,
3955
4284
  this.discovery,
@@ -3968,6 +4297,7 @@ export class Broker {
3968
4297
  this.#heartbeatTimer = setInterval(() => {
3969
4298
  void this.#watchPublication();
3970
4299
  void this.#reapSpawnOrphans();
4300
+ void this.#reapSelfIfAbandoned();
3971
4301
  }, cadenceMs);
3972
4302
  if (this.#startupPostPublicationDelayMs > 0) await Bun.sleep(this.#startupPostPublicationDelayMs);
3973
4303
  // This process is now the independently verified successor: its own
@@ -4017,6 +4347,10 @@ export class Broker {
4017
4347
  this.discovery?.ownerId === this.#owner
4018
4348
  );
4019
4349
  }
4350
+ /** Whether this broker completed its initial retained discovery publication. */
4351
+ get hasPublishedDiscovery(): boolean {
4352
+ return this.#publishedAt !== null;
4353
+ }
4020
4354
  get completion(): Promise<void> {
4021
4355
  return this.#completion;
4022
4356
  }
@@ -4851,7 +5185,19 @@ export class Broker {
4851
5185
  }
4852
5186
 
4853
5187
  // Wire requests are readiness-gated by BrokerTransport before reaching this dispatcher.
4854
- handleRequest(operation: string, input: Record<string, unknown>, idempotencyKey?: string): Promise<BrokerResponse> {
5188
+ async handleRequest(
5189
+ operation: string,
5190
+ input: Record<string, unknown>,
5191
+ idempotencyKey?: string,
5192
+ ): Promise<BrokerResponse> {
5193
+ if (
5194
+ this.#publication !== null &&
5195
+ this.#publicationState === "healthy-owned" &&
5196
+ !(await this.#hasCanonicalLockOwnership())
5197
+ ) {
5198
+ this.#fence("suspect-unpublished");
5199
+ return error("unavailable", "broker lock ownership is not held");
5200
+ }
4855
5201
  if (operation === "broker.status") return Promise.resolve({ ok: true, result: this.status() });
4856
5202
  if (operation === "broker.prepare_restart")
4857
5203
  return this.prepareRestart(input as unknown as BrokerRestartPrepareOptions).then(
@@ -4876,6 +5222,32 @@ export class Broker {
4876
5222
  this.#admitted.delete(admission);
4877
5223
  });
4878
5224
  }
5225
+ /**
5226
+ * Session-targeted lifecycle requests may pass terminal legacy rows recorded
5227
+ * for other sessions. A row equal to this key's legacy target identity for its
5228
+ * recorded session proves cross-target key reuse and keeps the fence; close
5229
+ * targets are reconstructed sessionId-only because legacy close rows may lack
5230
+ * endpoint authority.
5231
+ */
5232
+ async #legacySessionTarget(
5233
+ operation: string,
5234
+ input: Record<string, unknown>,
5235
+ idempotencyKey: string,
5236
+ ): Promise<{ sessionId: string; keyReuseIdentities: ReadonlySet<string> } | undefined> {
5237
+ if (!SESSION_TARGETED_LIFECYCLE_OPERATIONS.has(operation)) return undefined;
5238
+ const { sessionId } = lifecycleTarget(operation, input) as { sessionId?: string };
5239
+ if (sessionId === undefined) return undefined;
5240
+ const keyReuseIdentities = new Set<string>();
5241
+ for (const recordedSessionId of this.ledger.terminalLegacySessionIds()) {
5242
+ const recordedTarget = createHash("sha256")
5243
+ .update(canonicalJson(lifecycleTarget(operation, { sessionId: recordedSessionId })))
5244
+ .digest("hex");
5245
+ keyReuseIdentities.add(
5246
+ await deriveLegacyTargetIdentity(this.settings.agentDir, operation, idempotencyKey, recordedTarget),
5247
+ );
5248
+ }
5249
+ return { sessionId, keyReuseIdentities };
5250
+ }
4879
5251
  async #handleRequest(
4880
5252
  operation: string,
4881
5253
  input: Record<string, unknown>,
@@ -5094,10 +5466,13 @@ export class Broker {
5094
5466
  } else if (
5095
5467
  this.ledger.hasLegacyIdentity(
5096
5468
  new Set(this.#spawnAuthority?.claims().map(claim => claim.lifecycleIdentity) ?? []),
5097
- { ignoreTerminalRows: operation === "session.create" },
5469
+ {
5470
+ ignoreFinalRows: operation === "session.create",
5471
+ sessionTarget: await this.#legacySessionTarget(operation, input, idempotencyKey),
5472
+ },
5098
5473
  )
5099
5474
  ) {
5100
- return error("idempotency_conflict", "legacy lifecycle request has an ambiguous target");
5475
+ return error("legacy_lifecycle_ambiguous", "legacy lifecycle request has an ambiguous target");
5101
5476
  }
5102
5477
  }
5103
5478
  }
@@ -5173,6 +5548,9 @@ export class Broker {
5173
5548
  const begun = await this.ledger.begin(identity, requestHash, metadata);
5174
5549
  if (begun.kind === "replay") {
5175
5550
  const replay = begun.entry.response as BrokerResponse;
5551
+ // Proven errors are immutable even when their response carries cleanup
5552
+ // metadata. Only unproven cleanup outcomes may execute again.
5553
+ if (begun.entry.state === "terminal_error") return replay;
5176
5554
  const cleanup = cleanupFromResponse(replay) ?? reconstructedDeleteCleanup;
5177
5555
  if (!cleanup) {
5178
5556
  if (
@@ -5217,7 +5595,7 @@ export class Broker {
5217
5595
  const outcome = await executeLifecycle(this, operation, input, identity, cleanup);
5218
5596
  const response = outcome.response;
5219
5597
  const storedResponse = credentialFreeLifecycleResponse(response) as BrokerResponse;
5220
- await this.ledger.transition(identity, lifecycleResponseState(response), {
5598
+ const recorded = await this.ledger.transition(identity, lifecycleResponseState(response), {
5221
5599
  ...(operation === "session.delete" &&
5222
5600
  typeof input.sessionId === "string" &&
5223
5601
  lifecycleResponseState(response) === "terminal_uncertain" &&
@@ -5229,6 +5607,10 @@ export class Broker {
5229
5607
  ...(outcome.durableEffects ? { durableEffects: outcome.durableEffects } : {}),
5230
5608
  ...(outcome.startupFailure ? { startupFailure: outcome.startupFailure } : {}),
5231
5609
  });
5610
+ // A late transition may have proven a terminal result already. The ledger
5611
+ // preserves that row; return it instead of the freshly computed response.
5612
+ if (recorded.state === "terminal_error" && recorded.response !== undefined)
5613
+ return recorded.response as BrokerResponse;
5232
5614
  return response;
5233
5615
  }
5234
5616
  if (begun.kind === "idempotency_conflict")
@@ -5241,7 +5623,7 @@ export class Broker {
5241
5623
  const outcome = await executeLifecycle(this, operation, input, identity, cleanup);
5242
5624
  const response = outcome.response;
5243
5625
  const storedResponse = credentialFreeLifecycleResponse(response) as BrokerResponse;
5244
- await this.ledger.transition(identity, lifecycleResponseState(response), {
5626
+ const recorded = await this.ledger.transition(identity, lifecycleResponseState(response), {
5245
5627
  ...(pendingCleanupSessionId(response) ? { intendedSessionId: pendingCleanupSessionId(response) } : {}),
5246
5628
  ...(operation === "session.delete" &&
5247
5629
  typeof input.sessionId === "string" &&
@@ -5254,6 +5636,8 @@ export class Broker {
5254
5636
  ...(outcome.durableEffects ? { durableEffects: outcome.durableEffects } : {}),
5255
5637
  ...(outcome.startupFailure ? { startupFailure: outcome.startupFailure } : {}),
5256
5638
  });
5639
+ if (recorded.state === "terminal_error" && recorded.response !== undefined)
5640
+ return recorded.response as BrokerResponse;
5257
5641
  return response;
5258
5642
  }
5259
5643
  if (begun.kind === "in_progress") return error("broker_restarting", "lifecycle operation is in progress");
@@ -5341,6 +5725,21 @@ export function setLockArtifactGraceForTest(broker: Broker, graceMs: number | un
5341
5725
  else lockArtifactGraceOverridesForTest.set(broker, graceMs);
5342
5726
  }
5343
5727
 
5728
+ /** Test-only barriers for exercising the cross-process broker-lock reclaim fence. */
5729
+ export function setBrokerLockReclaimHooksForTest(
5730
+ broker: Broker,
5731
+ hooks: { beforeQuarantine?: () => Promise<void>; onContended?: () => void } | undefined,
5732
+ ): void {
5733
+ if (hooks === undefined) brokerLockReclaimHooksForTest.delete(broker);
5734
+ else brokerLockReclaimHooksForTest.set(broker, hooks);
5735
+ }
5736
+
5737
+ /** Test-only hook for shortening the bounded abandonment grace. */
5738
+ export function setAbandonedGraceForTest(broker: Broker, graceMs: number | undefined): void {
5739
+ if (graceMs === undefined) abandonedGraceOverridesForTest.delete(broker);
5740
+ else abandonedGraceOverridesForTest.set(broker, graceMs);
5741
+ }
5742
+
5344
5743
  /** Test-only hook for shortening the bounded liveness deadline. */
5345
5744
  export function setLivenessGraceForTest(broker: Broker, graceMs: number | undefined): void {
5346
5745
  if (graceMs === undefined) livenessGraceOverridesForTest.delete(broker);