@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
@@ -2,10 +2,18 @@ import { type ChildProcess, spawn } from "node:child_process";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import type { FileHandle } from "node:fs/promises";
4
4
  import * as fs from "node:fs/promises";
5
+ import * as os from "node:os";
5
6
  import path from "node:path";
7
+ import { type ProjectEnvSnapshot, projectEnvSnapshot } from "@gajae-code/utils/env-file";
8
+ import { nativeProcessBindings } from "@gajae-code/utils/native-process";
6
9
  import packageJson from "../../../package.json" with { type: "json" };
7
- import { acquireFileLock, type FileLockOptions, withFileLock } from "../../config/file-lock";
8
- import { loadInstallationHostId, loadLegacyInstallationHostId } from "../../config/machine-identity";
10
+ import {
11
+ acquireFileLock,
12
+ FILE_LOCK_ORPHAN_TRANSITION_ADOPTION_AGE_MS,
13
+ type FileLockOptions,
14
+ withFileLock,
15
+ } from "../../config/file-lock";
16
+ import { trustedCoordinatorEnv } from "../../gjc-runtime/sidecar-bootstrap-env";
9
17
  import { SdkClient, SdkClientError } from "../client/client";
10
18
  import { type BrokerStartupExitRecord, clearBrokerStartupExitRecord, readBrokerStartupExitRecord } from "./broker-exit";
11
19
  import {
@@ -14,7 +22,13 @@ import {
14
22
  readBrokerDiscovery,
15
23
  readBrokerRestartIntent,
16
24
  } from "./discovery";
17
- import { isProcessIncarnation, observeProcessIncarnation } from "./process-incarnation";
25
+ import { BROKER_HANDOFF_ABORT, BROKER_HANDOFF_ACKNOWLEDGEMENT, BROKER_HANDOFF_COMMIT } from "./hop";
26
+ import { brokerLockHostIdentity } from "./lock-host-identity";
27
+ import {
28
+ isProcessIncarnation,
29
+ observeProcessIncarnation,
30
+ type ProcessIncarnationObservation,
31
+ } from "./process-incarnation";
18
32
  import {
19
33
  isSdkInternalRuntimeImagePresent,
20
34
  resolveSdkInternalSpawnCommand,
@@ -28,6 +42,567 @@ import {
28
42
  readBrokerStartupFailureMarker,
29
43
  } from "./startup-failure";
30
44
 
45
+ /** Result of launching a broker through a hop/trampoline or as an exact fixture child. */
46
+ export interface BrokerSpawnResult {
47
+ /** The launcher process, or the exact broker process for fixture leases. */
48
+ process: ChildProcess;
49
+ /** The real broker's pid (from the launcher reply or exact child). */
50
+ realBrokerPid: number | undefined;
51
+ /** Kernel identity returned by the POSIX trampoline when available. */
52
+ realBrokerIncarnation?: string;
53
+ handoff?: BrokerLaunchHandoff;
54
+ }
55
+
56
+ export interface BrokerLaunchHandoff {
57
+ release(): Promise<void>;
58
+ terminate(): Promise<void>;
59
+ }
60
+
61
+ /**
62
+ * Typed error returned when a Windows broker hop fails.
63
+ * The hop is mandatory on Windows for broker detachment; failure is unrecoverable.
64
+ */
65
+ export class BrokerHopError extends Error {
66
+ readonly code = "broker_hop_failed";
67
+ readonly hopExitCode: number | null;
68
+ readonly hopStdout: string;
69
+ readonly hopStderr: string;
70
+ readonly reason: string;
71
+
72
+ constructor(fields: { exitCode: number | null; stdout: string; stderr: string; reason: string }) {
73
+ const stderr = fields.stderr.trim();
74
+ super(`Windows broker hop failed: ${fields.reason}${stderr ? `; ${stderr}` : ""}`);
75
+ this.name = "BrokerHopError";
76
+ this.hopExitCode = fields.exitCode;
77
+ this.hopStdout = fields.stdout;
78
+ this.hopStderr = fields.stderr;
79
+ this.reason = fields.reason;
80
+ }
81
+ }
82
+
83
+ export function resolveBrokerLaunchMode(
84
+ platform: NodeJS.Platform,
85
+ initiator: "discovery" | "fixture-lease",
86
+ ): "windows-hop" | "posix-trampoline" | "darwin-trampoline" | "direct" {
87
+ if (initiator === "fixture-lease") return "direct";
88
+ if (platform === "win32") return "windows-hop";
89
+ return platform === "darwin" ? "darwin-trampoline" : "posix-trampoline";
90
+ }
91
+
92
+ /**
93
+ * Resolve the hop invocation command for the current runtime (source or compiled).
94
+ * Returns the executable path and arguments to spawn the hop process.
95
+ */
96
+ function resolveHopInvocation(hopMessage: string): { file: string; args: string[] } {
97
+ const brokerCmd = resolveSdkInternalSpawnCommand("broker-internal");
98
+ if (brokerCmd.kind === "bun-source") {
99
+ // Source mode: args are ["--no-env-file", "--config=...", cli.ts, "sdk", "broker-internal"]
100
+ // Replace the last two with ["internal", "broker-hop", hopMessage]
101
+ const args = [...brokerCmd.args.slice(0, -2), "internal", "broker-hop", hopMessage];
102
+ return { file: brokerCmd.file, args };
103
+ } else {
104
+ // Compiled mode: file is gjc executable
105
+ return { file: brokerCmd.file, args: ["internal", "broker-hop", hopMessage] };
106
+ }
107
+ }
108
+
109
+ /** Spawn a broker as an exact child so its process handle can be retained for cleanup. */
110
+ function spawnBrokerDirect(
111
+ brokerFile: string,
112
+ brokerArgs: readonly string[],
113
+ options: {
114
+ stdioFd?: number;
115
+ env: NodeJS.ProcessEnv;
116
+ cwd?: string;
117
+ detached: boolean;
118
+ },
119
+ ): BrokerSpawnResult {
120
+ const child = spawn(brokerFile, Array.from(brokerArgs), {
121
+ detached: options.detached,
122
+ stdio: ["ignore", "ignore", options.stdioFd ?? "ignore"],
123
+ env: options.env,
124
+ ...(options.cwd ? { cwd: options.cwd } : {}),
125
+ });
126
+ return { process: child, realBrokerPid: child.pid };
127
+ }
128
+
129
+ /** What the parent sends the Windows hop. The broker environment is never serialized here. */
130
+ export interface BrokerHopMessage {
131
+ command: { file: string; args: string[] };
132
+ cwd?: string;
133
+ /** Path (never an inherited fd number) the hop opens for the broker's stderr. */
134
+ stderrLogPath?: string;
135
+ }
136
+
137
+ export interface BrokerLaunchResult {
138
+ process: ChildProcess;
139
+ realBrokerPid: number | undefined;
140
+ realBrokerIncarnation?: string;
141
+ error: Error | undefined;
142
+ handoff?: BrokerLaunchHandoff;
143
+ }
144
+
145
+ const MAX_LAUNCHER_OUTPUT_CHARS = 4_096;
146
+ const DEFAULT_LAUNCHER_TIMEOUT_MS = 30_000;
147
+ const HOP_HANDOFF_CLEANUP_TIMEOUT_MS = 1_000;
148
+
149
+ function appendBoundedOutput(current: string, chunk: Buffer | string): string {
150
+ const next = current + chunk.toString();
151
+ return next.length <= MAX_LAUNCHER_OUTPUT_CHARS ? next : next.slice(-MAX_LAUNCHER_OUTPUT_CHARS);
152
+ }
153
+
154
+ function endBrokerLauncherInput(child: ChildProcess, chunk?: string): void {
155
+ const input = child.stdin;
156
+ if (!input || input.destroyed || input.writableEnded) return;
157
+ try {
158
+ input.end(chunk);
159
+ } catch {
160
+ // The launcher treats an ended acknowledgement pipe as a failed handoff.
161
+ }
162
+ }
163
+
164
+ function writeBrokerLauncherInput(child: ChildProcess, chunk: string): Promise<void> {
165
+ const input = child.stdin;
166
+ if (!input || input.destroyed || input.writableEnded)
167
+ return Promise.reject(new Error("Broker trampoline control pipe is closed."));
168
+ const { promise, resolve, reject } = Promise.withResolvers<void>();
169
+ try {
170
+ input.write(chunk, error => (error ? reject(error) : resolve()));
171
+ } catch (error) {
172
+ reject(error instanceof Error ? error : new Error(String(error)));
173
+ }
174
+ return promise;
175
+ }
176
+
177
+ function awaitLauncherClose(child: ChildProcess): Promise<{ code: number | null; spawnError?: Error }> {
178
+ const { promise, resolve } = Promise.withResolvers<{ code: number | null; spawnError?: Error }>();
179
+ let spawnError: Error | undefined;
180
+ child.once("error", error => {
181
+ spawnError = error;
182
+ resolve({ code: null, spawnError });
183
+ });
184
+ child.once("close", code => resolve({ code, ...(spawnError ? { spawnError } : {}) }));
185
+ return promise;
186
+ }
187
+
188
+ async function terminateLauncher(child: ChildProcess): Promise<boolean> {
189
+ if (child.exitCode !== null || child.signalCode !== null) return true;
190
+ const exited = Promise.withResolvers<void>();
191
+ child.once("exit", exited.resolve);
192
+ child.once("close", exited.resolve);
193
+ child.on("error", () => {});
194
+ try {
195
+ child.kill("SIGKILL");
196
+ } catch {
197
+ // The exit events below still distinguish a launcher that already exited.
198
+ }
199
+ await Promise.race([exited.promise, Bun.sleep(1_000)]);
200
+ return child.exitCode !== null || child.signalCode !== null;
201
+ }
202
+
203
+ async function awaitLauncherCloseBeforeDeadline(
204
+ child: ChildProcess,
205
+ timeoutMs: number,
206
+ onTimeout?: () => void,
207
+ ): Promise<
208
+ { kind: "closed"; outcome: { code: number | null; spawnError?: Error } } | { kind: "timeout"; terminated: boolean }
209
+ > {
210
+ const closed = awaitLauncherClose(child);
211
+ const result = await Promise.race([
212
+ closed.then(outcome => ({ kind: "closed" as const, outcome })),
213
+ Bun.sleep(timeoutMs).then(() => ({ kind: "timeout" as const })),
214
+ ]);
215
+ if (result.kind === "closed") return result;
216
+ if (onTimeout) {
217
+ onTimeout();
218
+ const cleanupFinished = await Promise.race([
219
+ closed.then(() => true),
220
+ Bun.sleep(HOP_HANDOFF_CLEANUP_TIMEOUT_MS).then(() => false),
221
+ ]);
222
+ if (cleanupFinished) return { kind: "timeout", terminated: true };
223
+ }
224
+ return { kind: "timeout", terminated: await terminateLauncher(child) };
225
+ }
226
+
227
+ /** Test hook: proves a stuck launcher is terminated at its deadline. */
228
+ export async function awaitBrokerLauncherForTest(
229
+ child: ChildProcess,
230
+ timeoutMs: number,
231
+ onTimeout?: () => void,
232
+ ): Promise<{ kind: "closed" } | { kind: "timeout"; terminated: boolean }> {
233
+ const result = await awaitLauncherCloseBeforeDeadline(child, timeoutMs, onTimeout);
234
+ return result.kind === "closed" ? { kind: "closed" } : result;
235
+ }
236
+
237
+ /**
238
+ * Windows only: launch the broker through the internal hop and await its reply.
239
+ *
240
+ * `taskkill /T /F` walks ParentProcessId, so a broker spawned directly by the client
241
+ * dies with it even when detached. The hop spawns the broker and exits after
242
+ * the parent acknowledges the complete reply, leaving the broker without a
243
+ * live parent in the client's tree. The broker inherits the environment set on
244
+ * the hop, never passed on the command line; stderr is passed as a path because
245
+ * a parent fd number does not exist inside the hop.
246
+ */
247
+ export async function launchBrokerViaHop(
248
+ message: BrokerHopMessage,
249
+ options: { env: NodeJS.ProcessEnv; cwd?: string; timeoutMs?: number },
250
+ ): Promise<BrokerLaunchResult> {
251
+ const timeoutMs = options.timeoutMs ?? DEFAULT_LAUNCHER_TIMEOUT_MS;
252
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new Error("Windows broker hop startup deadline elapsed.");
253
+ const hopCmd = resolveHopInvocation(JSON.stringify(message));
254
+ const hop = spawn(hopCmd.file, hopCmd.args, {
255
+ detached: false,
256
+ windowsHide: true,
257
+ stdio: ["pipe", "pipe", "pipe"],
258
+ env: options.env,
259
+ ...(options.cwd ? { cwd: options.cwd } : {}),
260
+ });
261
+ let stdout = "";
262
+ let stderr = "";
263
+ let acknowledgementSent = false;
264
+ hop.stdout?.on("data", chunk => {
265
+ stdout = appendBoundedOutput(stdout, chunk);
266
+ if (acknowledgementSent) return;
267
+ // A successful hop write callback does not prove this parent received the
268
+ // record. Keep the hop's pinned broker until this complete identity parses.
269
+ const newlineIndex = stdout.indexOf("\n");
270
+ if (newlineIndex < 0) return;
271
+ acknowledgementSent = true;
272
+ const replyText = stdout.slice(0, newlineIndex + 1);
273
+ const trailingOutput = stdout.slice(newlineIndex + 1);
274
+ const parsed = parseBrokerHopReply(0, replyText);
275
+ if (parsed.error === undefined && trailingOutput.length === 0)
276
+ endBrokerLauncherInput(hop, BROKER_HANDOFF_ACKNOWLEDGEMENT);
277
+ else endBrokerLauncherInput(hop);
278
+ });
279
+ hop.stderr?.on("data", chunk => {
280
+ stderr = appendBoundedOutput(stderr, chunk);
281
+ });
282
+ hop.stdin?.on("error", () => {});
283
+ const wait = await awaitLauncherCloseBeforeDeadline(hop, timeoutMs, () => {
284
+ hop.stdout?.destroy();
285
+ endBrokerLauncherInput(hop);
286
+ });
287
+ if (wait.kind === "timeout") {
288
+ return await brokerHopTimeoutResult(hop, stdout, stderr, timeoutMs, wait.terminated);
289
+ }
290
+ return { process: hop, ...parseBrokerHopReply(wait.outcome.code, stdout, wait.outcome.spawnError, stderr) };
291
+ }
292
+
293
+ async function brokerHopTimeoutResult(
294
+ hop: ChildProcess,
295
+ stdout: string,
296
+ stderr: string,
297
+ timeoutMs: number,
298
+ launcherTerminated: boolean,
299
+ ): Promise<BrokerLaunchResult> {
300
+ // A complete reply can arrive before the hop's close event. Preserve its
301
+ // incarnation and reap that exact broker instead of leaving it detached after
302
+ // reporting a timeout to the caller.
303
+ const handoff = parseBrokerHopReply(0, stdout, undefined, stderr);
304
+ const brokerDetail = await reapTimedOutBrokerHandoff(handoff.realBrokerPid, handoff.realBrokerIncarnation);
305
+ const launcherDetail = launcherTerminated
306
+ ? "hop exited after handoff cleanup"
307
+ : "hop did not exit after termination";
308
+ return {
309
+ process: hop,
310
+ realBrokerPid: handoff.realBrokerPid,
311
+ ...(handoff.realBrokerIncarnation !== undefined ? { realBrokerIncarnation: handoff.realBrokerIncarnation } : {}),
312
+ error: new BrokerHopError({
313
+ exitCode: hop.exitCode,
314
+ stdout,
315
+ stderr,
316
+ reason: `hop exceeded its ${timeoutMs}ms startup deadline; ${launcherDetail}${brokerDetail}`,
317
+ }),
318
+ };
319
+ }
320
+
321
+ async function reapTimedOutBrokerHandoff(
322
+ realBrokerPid: number | undefined,
323
+ realBrokerIncarnation: string | undefined,
324
+ ): Promise<string> {
325
+ if (realBrokerPid === undefined || realBrokerIncarnation === undefined) return "";
326
+ try {
327
+ await reapDetachedBrokerPid(realBrokerPid, realBrokerIncarnation, {
328
+ gracefulMs: HOP_HANDOFF_CLEANUP_TIMEOUT_MS,
329
+ killVerifyMs: HOP_HANDOFF_CLEANUP_TIMEOUT_MS,
330
+ });
331
+ return "; captured broker handoff was reaped";
332
+ } catch (error) {
333
+ const detail = error instanceof Error ? error.message : String(error);
334
+ return `; exact broker cleanup failed: ${detail}`;
335
+ }
336
+ }
337
+
338
+ /** Parses the hop's single-line pid/incarnation reply into broker identity or a typed error. */
339
+ export function parseBrokerHopReply(
340
+ code: number | null,
341
+ stdout: string,
342
+ spawnError?: Error,
343
+ stderr = "",
344
+ ): { realBrokerPid: number | undefined; realBrokerIncarnation?: string; error: Error | undefined } {
345
+ const fail = (reason: string) => ({
346
+ realBrokerPid: undefined,
347
+ error: new BrokerHopError({ exitCode: code, stdout, stderr, reason }),
348
+ });
349
+ if (spawnError) return fail(`hop could not be spawned: ${spawnError.message}`);
350
+ if (code !== 0) return fail(`hop process exited with non-zero code ${code}`);
351
+ const line = stdout.trim();
352
+ if (!line) return fail("hop exited with code 0 but no response on stdout");
353
+ let reply: unknown;
354
+ try {
355
+ reply = JSON.parse(line);
356
+ } catch (error) {
357
+ return fail(`failed to parse hop JSON response: ${error instanceof Error ? error.message : String(error)}`);
358
+ }
359
+ const pid = (reply as { pid?: unknown } | null)?.pid;
360
+ if (typeof pid !== "number" || !Number.isSafeInteger(pid) || pid <= 0)
361
+ return fail(`hop response missing or invalid pid: ${String(pid)}`);
362
+ const incarnation = (reply as { incarnation?: unknown } | null)?.incarnation;
363
+ if (!isProcessIncarnation(incarnation)) return fail("hop response missing or invalid process incarnation");
364
+ return { realBrokerPid: pid, realBrokerIncarnation: incarnation, error: undefined };
365
+ }
366
+
367
+ /** POSIX only: the trampoline holds its exact broker child until the publication decision. */
368
+ export async function launchBrokerViaPosixTrampoline(
369
+ agentDir: string,
370
+ options: {
371
+ env: NodeJS.ProcessEnv;
372
+ cwd?: string;
373
+ stderrFd?: number;
374
+ timeoutMs?: number;
375
+ retainChildUntilPublication?: boolean;
376
+ },
377
+ ): Promise<BrokerLaunchResult> {
378
+ const timeoutMs = options.timeoutMs ?? DEFAULT_LAUNCHER_TIMEOUT_MS;
379
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0)
380
+ throw new Error("POSIX broker trampoline startup deadline elapsed.");
381
+ const command = resolveSdkInternalSpawnCommand("broker-trampoline-internal");
382
+ const trampolineEnvironment = { ...options.env };
383
+ if (options.retainChildUntilPublication) trampolineEnvironment.GJC_BROKER_TRAMPOLINE_RETAIN_UNTIL_PUBLICATION = "1";
384
+ else delete trampolineEnvironment.GJC_BROKER_TRAMPOLINE_RETAIN_UNTIL_PUBLICATION;
385
+ const child = spawn(command.file, [...command.args, "--agent-dir", agentDir], {
386
+ detached: true,
387
+ stdio: ["pipe", "pipe", options.stderrFd ?? "ignore"],
388
+ env: trampolineEnvironment,
389
+ ...(options.cwd ? { cwd: options.cwd } : {}),
390
+ });
391
+ if (options.retainChildUntilPublication) return await launchBrokerViaRetainedPosixTrampoline(child, timeoutMs);
392
+ let stdout = "";
393
+ let acknowledgementSent = false;
394
+ child.stdout?.on("data", chunk => {
395
+ stdout = appendBoundedOutput(stdout, chunk);
396
+ if (acknowledgementSent) return;
397
+ const newlineIndex = stdout.indexOf("\n");
398
+ if (newlineIndex < 0) return;
399
+ acknowledgementSent = true;
400
+ const replyText = stdout.slice(0, newlineIndex + 1);
401
+ const trailingOutput = stdout.slice(newlineIndex + 1);
402
+ const parsed = parseBrokerTrampolineReply(0, replyText);
403
+ if (parsed.error === undefined && trailingOutput.length === 0)
404
+ endBrokerLauncherInput(child, BROKER_HANDOFF_ACKNOWLEDGEMENT);
405
+ else endBrokerLauncherInput(child);
406
+ });
407
+ child.stdin?.on("error", () => {});
408
+ const wait = await awaitLauncherCloseBeforeDeadline(child, timeoutMs, () => {
409
+ child.stdout?.destroy();
410
+ endBrokerLauncherInput(child);
411
+ });
412
+ if (wait.kind === "timeout") {
413
+ return await brokerTrampolineTimeoutResult(child, stdout, timeoutMs, wait.terminated);
414
+ }
415
+ const parsed = parseBrokerTrampolineReply(wait.outcome.code, stdout, wait.outcome.spawnError);
416
+ return { process: child, ...parsed };
417
+ }
418
+
419
+ const RETAINED_TRAMPOLINE_CLEANUP_TIMEOUT_MS = 5_000;
420
+
421
+ function createBrokerLaunchHandoff(
422
+ trampoline: ChildProcess,
423
+ brokerPid: number,
424
+ brokerIncarnation: string,
425
+ ): BrokerLaunchHandoff {
426
+ const closed = awaitLauncherClose(trampoline);
427
+ let disposition: "release" | "terminate" | undefined;
428
+ let completion: Promise<void> | undefined;
429
+ const finish = (requested: "release" | "terminate"): Promise<void> => {
430
+ if (completion) {
431
+ if (disposition !== requested)
432
+ return Promise.reject(new Error(`Broker trampoline already received ${disposition}.`));
433
+ return completion;
434
+ }
435
+ disposition = requested;
436
+ completion = (async () => {
437
+ const message = requested === "release" ? BROKER_HANDOFF_COMMIT : BROKER_HANDOFF_ABORT;
438
+ let inputError: unknown;
439
+ if (!trampoline.stdin?.destroyed && !trampoline.stdin?.writableEnded) {
440
+ try {
441
+ await writeBrokerLauncherInput(trampoline, message);
442
+ } catch (error) {
443
+ inputError = error;
444
+ } finally {
445
+ endBrokerLauncherInput(trampoline);
446
+ }
447
+ } else if (requested === "release") {
448
+ inputError = new Error("POSIX broker trampoline control pipe closed before publication commit.");
449
+ }
450
+ const outcome = await Promise.race([
451
+ closed,
452
+ Bun.sleep(RETAINED_TRAMPOLINE_CLEANUP_TIMEOUT_MS).then(() => undefined),
453
+ ]);
454
+ if (!outcome) throw new Error("POSIX broker trampoline did not complete its publication decision.");
455
+ if (outcome.spawnError) throw outcome.spawnError;
456
+ if (requested === "release") {
457
+ if (inputError)
458
+ throw new Error("Could not commit the POSIX broker handoff after publication.", { cause: inputError });
459
+ if (outcome.code !== 0)
460
+ throw new Error(`POSIX broker trampoline exited with code ${outcome.code} before release completed.`);
461
+ return;
462
+ }
463
+ const observation = observeProcessIncarnation(brokerPid);
464
+ if (
465
+ observation.status !== "absent" &&
466
+ !(observation.status === "present" && observation.incarnation !== brokerIncarnation)
467
+ )
468
+ throw new Error(`POSIX broker trampoline did not verify cleanup of broker ${brokerPid}.`);
469
+ })();
470
+ return completion;
471
+ };
472
+ return { release: () => finish("release"), terminate: () => finish("terminate") };
473
+ }
474
+
475
+ async function launchBrokerViaRetainedPosixTrampoline(
476
+ trampoline: ChildProcess,
477
+ timeoutMs: number,
478
+ ): Promise<BrokerLaunchResult> {
479
+ let stdout = "";
480
+ let parsed: ReturnType<typeof parseBrokerTrampolineReply> | undefined;
481
+ let failure: Error | undefined;
482
+ let settled = false;
483
+ const reply = Promise.withResolvers<void>();
484
+ const finish = (error?: Error): void => {
485
+ if (settled) return;
486
+ settled = true;
487
+ failure = error;
488
+ reply.resolve();
489
+ };
490
+ const closed = awaitLauncherClose(trampoline);
491
+ void closed.then(outcome => {
492
+ if (settled) return;
493
+ const result = parseBrokerTrampolineReply(outcome.code, stdout, outcome.spawnError);
494
+ parsed = result;
495
+ finish(result.error ?? new Error("POSIX broker trampoline exited before publication was confirmed."));
496
+ });
497
+ trampoline.stdout?.on("data", chunk => {
498
+ stdout = appendBoundedOutput(stdout, chunk);
499
+ if (settled) return;
500
+ const newlineIndex = stdout.indexOf("\n");
501
+ if (newlineIndex < 0) return;
502
+ const replyText = stdout.slice(0, newlineIndex + 1);
503
+ const trailingOutput = stdout.slice(newlineIndex + 1);
504
+ const result = parseBrokerTrampolineReply(0, replyText);
505
+ if (result.error || trailingOutput.length > 0) {
506
+ parsed = result;
507
+ endBrokerLauncherInput(trampoline);
508
+ finish(result.error ?? new Error("POSIX broker trampoline wrote unexpected output."));
509
+ return;
510
+ }
511
+ parsed = result;
512
+ void writeBrokerLauncherInput(trampoline, BROKER_HANDOFF_ACKNOWLEDGEMENT).then(
513
+ () => finish(),
514
+ error => {
515
+ endBrokerLauncherInput(trampoline);
516
+ finish(error instanceof Error ? error : new Error(String(error)));
517
+ },
518
+ );
519
+ });
520
+ trampoline.stdin?.on("error", () => {});
521
+ const wait = await Promise.race([
522
+ reply.promise.then(() => ({ kind: "reply" as const })),
523
+ Bun.sleep(timeoutMs).then(() => ({ kind: "timeout" as const })),
524
+ ]);
525
+ const identity = {
526
+ realBrokerPid: parsed?.realBrokerPid,
527
+ ...(parsed?.realBrokerIncarnation === undefined ? {} : { realBrokerIncarnation: parsed.realBrokerIncarnation }),
528
+ };
529
+ if (wait.kind === "timeout") {
530
+ endBrokerLauncherInput(trampoline);
531
+ return {
532
+ process: trampoline,
533
+ ...identity,
534
+ ...(identity.realBrokerPid !== undefined && identity.realBrokerIncarnation !== undefined
535
+ ? { handoff: createBrokerLaunchHandoff(trampoline, identity.realBrokerPid, identity.realBrokerIncarnation) }
536
+ : {}),
537
+ error: new Error(`POSIX broker trampoline did not report a verified identity within ${timeoutMs}ms.`),
538
+ };
539
+ }
540
+ if (failure) {
541
+ endBrokerLauncherInput(trampoline);
542
+ return {
543
+ process: trampoline,
544
+ ...identity,
545
+ ...(identity.realBrokerPid !== undefined && identity.realBrokerIncarnation !== undefined
546
+ ? { handoff: createBrokerLaunchHandoff(trampoline, identity.realBrokerPid, identity.realBrokerIncarnation) }
547
+ : {}),
548
+ error: failure,
549
+ };
550
+ }
551
+ if (identity.realBrokerPid === undefined || identity.realBrokerIncarnation === undefined)
552
+ return {
553
+ process: trampoline,
554
+ ...identity,
555
+ error: new Error("POSIX broker trampoline returned no verified process identity."),
556
+ };
557
+ return {
558
+ process: trampoline,
559
+ ...identity,
560
+ error: undefined,
561
+ handoff: createBrokerLaunchHandoff(trampoline, identity.realBrokerPid, identity.realBrokerIncarnation),
562
+ };
563
+ }
564
+
565
+ async function brokerTrampolineTimeoutResult(
566
+ trampoline: ChildProcess,
567
+ stdout: string,
568
+ timeoutMs: number,
569
+ trampolineTerminated: boolean,
570
+ ): Promise<BrokerLaunchResult> {
571
+ const handoff = parseBrokerTrampolineReply(0, stdout);
572
+ const brokerDetail = await reapTimedOutBrokerHandoff(handoff.realBrokerPid, handoff.realBrokerIncarnation);
573
+ const trampolineDetail = trampolineTerminated
574
+ ? "trampoline was terminated"
575
+ : "trampoline did not exit after termination";
576
+ const handoffFailure = handoff.realBrokerPid === undefined && handoff.error ? `; ${handoff.error.message}` : "";
577
+ return {
578
+ process: trampoline,
579
+ realBrokerPid: handoff.realBrokerPid,
580
+ ...(handoff.realBrokerIncarnation !== undefined ? { realBrokerIncarnation: handoff.realBrokerIncarnation } : {}),
581
+ error: new Error(
582
+ `POSIX broker trampoline exceeded its ${timeoutMs}ms startup deadline; ${trampolineDetail}${brokerDetail}${handoffFailure}`,
583
+ ),
584
+ };
585
+ }
586
+
587
+ export function parseBrokerTrampolineReply(
588
+ code: number | null,
589
+ stdout: string,
590
+ spawnError?: Error,
591
+ ): { realBrokerPid: number | undefined; realBrokerIncarnation?: string; error: Error | undefined } {
592
+ const fail = (reason: string) => ({
593
+ realBrokerPid: undefined,
594
+ error: new Error(`POSIX broker trampoline failed: ${reason}`),
595
+ });
596
+ if (spawnError) return fail(`could not be spawned: ${spawnError.message}`);
597
+ if (code !== 0) return fail(`exited with non-zero code ${code}: ${stdout.trim()}`);
598
+ const match = /^(\d+)\t([^\t\r\n]+)$/.exec(stdout.trim());
599
+ const pid = match ? Number(match[1]) : Number.NaN;
600
+ const incarnation = match?.[2];
601
+ if (!Number.isSafeInteger(pid) || pid <= 0 || !isProcessIncarnation(incarnation))
602
+ return fail("reply did not contain a valid pid and process incarnation");
603
+ return { realBrokerPid: pid, realBrokerIncarnation: incarnation, error: undefined };
604
+ }
605
+
31
606
  function resolveExpectedBrokerGeneration(): string {
32
607
  const v = (packageJson as { version?: unknown }).version;
33
608
  return typeof v === "string" && v.length > 0 ? v : "unknown";
@@ -39,15 +614,36 @@ function brokerStartupFailureReason(marker: BrokerStartupFailureMarker | undefin
39
614
  return marker.reason;
40
615
  }
41
616
 
617
+ function brokerSpawnFailureError(spawnError: Error): Error {
618
+ return spawnError instanceof BrokerHopError
619
+ ? spawnError
620
+ : new Error(`Failed to spawn detached SDK broker: ${spawnError.message}`);
621
+ }
622
+
42
623
  function brokerStartupExitReason(record: BrokerStartupExitRecord | undefined): string | undefined {
43
624
  if (!record) return undefined;
44
625
  if (record.reason === "startup-lock-blocked")
45
626
  return `SDK broker startup blocked by retained removal transition ${record.blockingLockPath}.`;
627
+ if (record.reason === "startup-lock-unavailable")
628
+ return `SDK broker startup could not acquire required lock ${record.blockingLockPath}.`;
46
629
  if (record.reason === "startup-deadline")
47
630
  return `SDK broker startup exceeded its ${record.timeoutMs}ms fence deadline.`;
48
631
  return `SDK broker startup interrupted by ${record.signal} before readiness.`;
49
632
  }
50
633
 
634
+ function brokerStartupExitStatus(
635
+ childExitCode: number | null,
636
+ childSignal: NodeJS.Signals | null,
637
+ startupExitRecord: Pick<BrokerStartupExitRecord, "exitCode" | "signal"> | undefined,
638
+ startupFailureMarker: Pick<BrokerStartupFailureMarker, "exitCode" | "signal"> | undefined,
639
+ ): { exitCode: number | null; signal: string | null } {
640
+ const brokerExitEvidence = startupExitRecord ?? startupFailureMarker;
641
+ return {
642
+ exitCode: brokerExitEvidence ? brokerExitEvidence.exitCode : childExitCode,
643
+ signal: brokerExitEvidence ? brokerExitEvidence.signal : childSignal,
644
+ };
645
+ }
646
+
51
647
  export function isBrokerGenerationCompatible(discovery: BrokerDiscovery | null): boolean {
52
648
  if (!discovery) return false;
53
649
  return discovery.packageGeneration === resolveExpectedBrokerGeneration();
@@ -83,13 +679,17 @@ export interface EnsureBrokerSettings {
83
679
  }
84
680
 
85
681
  const BROKER_PUBLICATION_TIMEOUT_MS = 15_000;
86
- const STARTUP_LOCK_WAIT_MS = 9_000;
87
682
  const STALE_BROKER_RETIREMENT_TIMEOUT_MS = 5_000;
683
+ const SPAWN_LOCK_RETRY_DELAY_MS = 50;
684
+ /** Keep the startup fence alive long enough to adopt an ownerless removal transition. */
685
+ const STARTUP_LOCK_WAIT_MS = Math.max(
686
+ 9_000,
687
+ FILE_LOCK_ORPHAN_TRANSITION_ADOPTION_AGE_MS + SPAWN_LOCK_RETRY_DELAY_MS * 2,
688
+ );
88
689
  /** Parent budget covers stale retirement, a full child-fence wait, and one publication attempt. */
89
690
  const DISCOVERY_TIMEOUT_MS = STALE_BROKER_RETIREMENT_TIMEOUT_MS + STARTUP_LOCK_WAIT_MS + BROKER_PUBLICATION_TIMEOUT_MS;
90
691
  /** A process that loses the spawn lock waits this long for the winner's discovery before giving up. */
91
692
  const SPAWN_LOCK_WAIT_MS = STALE_BROKER_RETIREMENT_TIMEOUT_MS + DISCOVERY_TIMEOUT_MS + 5_000;
92
- const SPAWN_LOCK_RETRY_DELAY_MS = 50;
93
693
  const SPAWN_LOCK_TARGET_NAME = "broker.spawn";
94
694
  const STARTUP_LOCK_TARGET_NAME = "broker.startup";
95
695
  const BROKER_SESSION_ENV_NAMES = new Set([
@@ -121,18 +721,71 @@ const BROKER_SESSION_ENV_PREFIXES = [
121
721
  "GJC_MANAGED_OWNER_",
122
722
  ] as const;
123
723
 
124
- export interface BrokerStartupLockTestHooks {
724
+ export interface BrokerStartupLockOptions {
725
+ /** Stop waiting for the startup lock at this absolute wall-clock deadline. */
726
+ deadlineAt?: number;
125
727
  onAcquired?: () => void;
126
728
  onContended?: () => void;
127
729
  }
128
730
 
731
+ const BROKER_TEST_SIGNAL_DIR_ENV = "GJC_SDK_TEST_BROKER_SIGNAL_DIR";
732
+ /** Bound the project dotenv read. A FIFO or a larger file is incomplete and rejected. */
733
+ const BROKER_SIGNAL_DOTENV_MAX_BYTES = 64 * 1024;
734
+
735
+ /**
736
+ * Dotenv from the cwd that first loaded this module. `launch --worktree` changes
737
+ * cwd after Bun has already copied that file into `process.env`.
738
+ */
739
+ const startupBrokerSignalSnapshot = projectEnvSnapshot(process.cwd(), {
740
+ maxFileBytes: BROKER_SIGNAL_DOTENV_MAX_BYTES,
741
+ });
742
+
743
+ function brokerSignalSnapshots(override?: readonly ProjectEnvSnapshot[]): readonly ProjectEnvSnapshot[] {
744
+ if (override) return override;
745
+ return [
746
+ projectEnvSnapshot(process.cwd(), { maxFileBytes: BROKER_SIGNAL_DOTENV_MAX_BYTES }),
747
+ startupBrokerSignalSnapshot,
748
+ ];
749
+ }
750
+
751
+ /**
752
+ * Operator environment only. A directory declared by the project dotenv is
753
+ * ignored so startup cannot create a path the repository chose.
754
+ * No signal value means no later dotenv read, so an unused seam cannot stall startup.
755
+ * An incomplete startup or cwd snapshot rejects the value.
756
+ */
757
+ function trustedBrokerTestSignalDirectory(
758
+ env: NodeJS.ProcessEnv,
759
+ snapshots?: readonly ProjectEnvSnapshot[],
760
+ ): string | undefined {
761
+ const raw = env[BROKER_TEST_SIGNAL_DIR_ENV];
762
+ if (!raw) return undefined;
763
+ let accepted: string | undefined;
764
+ for (const snapshot of brokerSignalSnapshots(snapshots)) {
765
+ if (snapshot.incomplete) return undefined;
766
+ const trusted = trustedCoordinatorEnv(BROKER_TEST_SIGNAL_DIR_ENV, env, snapshot);
767
+ if (trusted === undefined) return undefined;
768
+ accepted = trusted;
769
+ }
770
+ return accepted;
771
+ }
772
+
773
+ /** Drop a project-dotenv signal directory. Keep a trusted directory as the value that was accepted. */
774
+ function scrubUntrustedBrokerTestSignalDirectory(environment: NodeJS.ProcessEnv): void {
775
+ const trusted = trustedBrokerTestSignalDirectory(environment);
776
+ for (const name of Object.keys(environment)) {
777
+ if (name.toUpperCase() === BROKER_TEST_SIGNAL_DIR_ENV) delete environment[name];
778
+ }
779
+ if (trusted !== undefined) environment[BROKER_TEST_SIGNAL_DIR_ENV] = trusted;
780
+ }
781
+
129
782
  /**
130
783
  * Test-only phase observation for detached broker startup regressions. The
131
- * signal is opt-in through the child environment and never affects broker
132
- * ownership or error handling when the test seam is absent.
784
+ * directory is opt-in through a trusted operator environment and never affects
785
+ * broker ownership or error handling when the seam is absent.
133
786
  */
134
787
  export async function emitBrokerStartupTestSignal(signal: string): Promise<void> {
135
- const directory = process.env.GJC_SDK_TEST_BROKER_SIGNAL_DIR;
788
+ const directory = trustedBrokerTestSignalDirectory(process.env);
136
789
  if (!directory || !/^[a-z0-9-]+$/.test(signal)) return;
137
790
  try {
138
791
  await fs.mkdir(directory, { recursive: true, mode: 0o700 });
@@ -189,33 +842,6 @@ export function setEnsureBrokerTimingForTest(timing: EnsureBrokerTiming | undefi
189
842
 
190
843
  type SpawnLockOptions = Pick<FileLockOptions, "retries" | "retryDelayMs" | "signal">;
191
844
 
192
- interface BrokerLockHostIdentity {
193
- ownerHostId: string;
194
- previousOwnerHostIds: readonly string[];
195
- }
196
-
197
- const brokerLockHostIdentityPromises = new Map<string, Promise<BrokerLockHostIdentity>>();
198
-
199
- async function brokerLockHostIdentity(agentDir: string): Promise<BrokerLockHostIdentity> {
200
- const coordinationRoot = path.resolve(agentDir, "sdk");
201
- const pending =
202
- brokerLockHostIdentityPromises.get(coordinationRoot) ??
203
- Promise.all([loadInstallationHostId({ configRootDir: coordinationRoot }), loadLegacyInstallationHostId()]).then(
204
- ([ownerHostId, legacyHostId]) => ({
205
- ownerHostId,
206
- previousOwnerHostIds: legacyHostId === ownerHostId ? [] : [legacyHostId],
207
- }),
208
- );
209
- brokerLockHostIdentityPromises.set(coordinationRoot, pending);
210
- try {
211
- return await pending;
212
- } catch (error) {
213
- if (brokerLockHostIdentityPromises.get(coordinationRoot) === pending)
214
- brokerLockHostIdentityPromises.delete(coordinationRoot);
215
- throw error;
216
- }
217
- }
218
-
219
845
  async function readBrokerDiscoveryBeforeDeadline(
220
846
  agentDir: string,
221
847
  heartbeatTtlMs: number | undefined,
@@ -268,17 +894,30 @@ async function acquireSpawnLock(agentDir: string, options: SpawnLockOptions = {}
268
894
  export async function withBrokerStartupLock<T>(
269
895
  agentDir: string,
270
896
  operation: (deadline: number) => Promise<T>,
271
- testHooks: BrokerStartupLockTestHooks = {},
897
+ options: BrokerStartupLockOptions = {},
272
898
  ): Promise<T> {
899
+ if (options.deadlineAt !== undefined && !Number.isFinite(options.deadlineAt))
900
+ throw new TypeError("Broker startup-lock deadline must be finite.");
273
901
  const hostIdentity = await brokerLockHostIdentity(agentDir);
902
+ const now = ensureBrokerTiming.now();
903
+ const lockWaitMs =
904
+ options.deadlineAt === undefined
905
+ ? STARTUP_LOCK_WAIT_MS
906
+ : Math.max(0, Math.min(STARTUP_LOCK_WAIT_MS, options.deadlineAt - now));
274
907
  return withFileLock(
275
908
  path.join(agentDir, "sdk", STARTUP_LOCK_TARGET_NAME),
276
- () => operation(ensureBrokerTiming.now() + BROKER_DISCOVERY_BUDGET.fenceOperationMs),
909
+ () => {
910
+ const operationDeadline = ensureBrokerTiming.now() + BROKER_DISCOVERY_BUDGET.fenceOperationMs;
911
+ return operation(
912
+ options.deadlineAt === undefined ? operationDeadline : Math.min(operationDeadline, options.deadlineAt),
913
+ );
914
+ },
277
915
  {
278
- retries: Math.ceil(STARTUP_LOCK_WAIT_MS / SPAWN_LOCK_RETRY_DELAY_MS),
916
+ retries: Math.ceil(lockWaitMs / SPAWN_LOCK_RETRY_DELAY_MS),
279
917
  retryDelayMs: SPAWN_LOCK_RETRY_DELAY_MS,
280
- onAcquired: testHooks.onAcquired,
281
- onContended: testHooks.onContended,
918
+ orphanTransitionAgeMs: FILE_LOCK_ORPHAN_TRANSITION_ADOPTION_AGE_MS,
919
+ onAcquired: options.onAcquired,
920
+ onContended: options.onContended,
282
921
  ...hostIdentity,
283
922
  },
284
923
  );
@@ -371,8 +1010,7 @@ export interface StartedFixtureBroker {
371
1010
  interface BrokerOwner {
372
1011
  stop(): Promise<void>;
373
1012
  canReuse(discovery: BrokerDiscovery | null): boolean;
374
- markReady(discovery: BrokerDiscovery): boolean;
375
- setTarget(pid: number, incarnation: string): void;
1013
+ markReady(discovery: BrokerDiscovery): Promise<boolean>;
376
1014
  }
377
1015
  type EnsureInitiator = "discovery" | "fixture-lease";
378
1016
  type EnsureOutcome =
@@ -392,6 +1030,7 @@ interface ReapTiming {
392
1030
  gracefulMs: number;
393
1031
  killVerifyMs: number;
394
1032
  }
1033
+ type DetachedBrokerSignal = (pid: number, brokerIncarnation: string, signal: NodeJS.Signals) => boolean;
395
1034
  const DEFAULT_REAP_TIMING: ReapTiming = {
396
1035
  gracefulMs: REAP_GRACEFUL_MS,
397
1036
  killVerifyMs: REAP_SIGKILL_CAP_MS,
@@ -408,12 +1047,25 @@ const DEFAULT_REAP_TIMING: ReapTiming = {
408
1047
  * teardown (e.g. a transient signal-delivery failure); that is diagnostic only
409
1048
  * and never counts as exit, so the escalation cannot be skipped mid-shutdown.
410
1049
  */
411
- async function reapSpawnedBroker(child: ChildProcess, timing: ReapTiming = DEFAULT_REAP_TIMING): Promise<void> {
1050
+ async function reapSpawnedBroker(
1051
+ child: ChildProcess,
1052
+ realBrokerPid?: number,
1053
+ brokerIncarnation?: string,
1054
+ timing: ReapTiming = DEFAULT_REAP_TIMING,
1055
+ ): Promise<void> {
1056
+ // `child` is the actual broker only for direct fixture launches; hop and
1057
+ // trampoline launches keep the broker identity separate from the launcher.
1058
+ const pidToReap = realBrokerPid ?? child.pid;
412
1059
  // A spawn failure (e.g. ENOENT) never created a kernel process: pid is
413
1060
  // undefined and there is nothing to signal or await. The `error` event is the
414
1061
  // only signal and is diagnostic here — termination trivially holds, so do not
415
1062
  // run out the TERM/KILL windows or report a stuck child that never existed.
416
- if (child.pid === undefined) return;
1063
+ if (pidToReap === undefined) return;
1064
+ if (pidToReap !== child.pid) {
1065
+ // The retained ChildProcess is a hop/trampoline, not the broker itself.
1066
+ await reapDetachedBrokerPid(pidToReap, brokerIncarnation, timing);
1067
+ return;
1068
+ }
417
1069
  // Reaping owns repeated teardown diagnostics too. Keep exactly one error
418
1070
  // listener for the retained child so a later signal-delivery error cannot
419
1071
  // become an unhandled EventEmitter error after the spawn listener is consumed.
@@ -454,81 +1106,196 @@ async function reapSpawnedBroker(child: ChildProcess, timing: ReapTiming = DEFAU
454
1106
  if (hasExited()) return;
455
1107
  // SIGKILL is uninterruptible; a child still alive past this bounded wait is a
456
1108
  // kernel-level stuck state. Surface it rather than silently orphaning the spawn.
457
- throw new Error(`Detached SDK broker (pid ${child.pid}) did not exit after SIGKILL during reap.`);
1109
+ throw new Error(`Detached SDK broker (pid ${pidToReap}) did not exit after SIGKILL during reap.`);
1110
+ }
1111
+
1112
+ /**
1113
+ * Reap a broker launched by a short-lived hop or trampoline. The launcher has
1114
+ * already exited, so its ChildProcess carries no signal to await: the real broker
1115
+ * is targeted by the reported pid, and every poll is fenced by incarnation.
1116
+ * Signals use a pinned OS process handle; unsupported platforms fail closed.
1117
+ */
1118
+ interface PinnedBrokerProcess {
1119
+ readonly incarnation: string;
1120
+ signalRoot(signal: number): boolean;
1121
+ }
1122
+
1123
+ function signalPinnedBrokerProcess(
1124
+ reference: PinnedBrokerProcess | null,
1125
+ expectedIncarnation: string,
1126
+ signal: NodeJS.Signals,
1127
+ ): boolean {
1128
+ if (!reference || reference.incarnation !== expectedIncarnation) return false;
1129
+ const signalNumber = os.constants.signals[signal];
1130
+ return signalNumber !== undefined && reference.signalRoot(signalNumber);
1131
+ }
1132
+
1133
+ function signalDetachedBrokerProcess(
1134
+ pid: number,
1135
+ brokerIncarnation: string,
1136
+ signal: NodeJS.Signals,
1137
+ platform: NodeJS.Platform = process.platform,
1138
+ ): boolean {
1139
+ // Darwin has no atomic identity-bound signal primitive. A separate
1140
+ // incarnation check cannot close the PID-reuse window before process.kill.
1141
+ if (platform === "darwin") return false;
1142
+ const reference = nativeProcessBindings().Process.fromPid(pid);
1143
+ return signalPinnedBrokerProcess(reference, brokerIncarnation, signal);
458
1144
  }
459
1145
 
460
- async function reapSpawnedBrokerIdentity(
1146
+ async function reapDetachedBrokerPid(
461
1147
  pid: number,
462
- incarnation: string,
1148
+ brokerIncarnation?: string,
463
1149
  timing: ReapTiming = DEFAULT_REAP_TIMING,
1150
+ observe: (pid: number) => ProcessIncarnationObservation = observeProcessIncarnation,
1151
+ signalProcess: DetachedBrokerSignal = signalDetachedBrokerProcess,
464
1152
  ): Promise<void> {
465
- const matches = (): boolean => {
466
- const observed = observeProcessIncarnation(pid);
467
- return observed.status === "present" && observed.incarnation === incarnation;
1153
+ const inspect = (): "owned" | "gone" | "unknown" => {
1154
+ const observation = observe(pid);
1155
+ if (observation.status === "absent") return "gone";
1156
+ if (observation.status !== "present" || !brokerIncarnation) return "unknown";
1157
+ return observation.incarnation === brokerIncarnation ? "owned" : "gone";
468
1158
  };
469
- const gone = (): boolean => observeProcessIncarnation(pid).status === "absent";
470
- const signal = (sig: NodeJS.Signals): void => {
471
- if (!matches()) return;
1159
+ const awaitGone = async (windowMs: number): Promise<boolean> => {
1160
+ const deadline = Date.now() + windowMs;
1161
+ for (;;) {
1162
+ const state = inspect();
1163
+ if (state === "gone") return true;
1164
+ if (Date.now() >= deadline) return false;
1165
+ await sleep(Math.min(20, Math.max(1, deadline - Date.now())));
1166
+ }
1167
+ };
1168
+ const signal = (sig: NodeJS.Signals): boolean => {
1169
+ const state = inspect();
1170
+ if (state === "gone") return false;
1171
+ if (state === "unknown") {
1172
+ throw new Error(`Detached SDK broker (pid ${pid}) cannot be signaled without a verified process incarnation.`);
1173
+ }
1174
+ if (!brokerIncarnation) {
1175
+ throw new Error(`Detached SDK broker (pid ${pid}) cannot be signaled without a verified process incarnation.`);
1176
+ }
1177
+ let delivered = false;
472
1178
  try {
473
- process.kill(pid, sig);
1179
+ delivered = signalProcess(pid, brokerIncarnation, sig);
474
1180
  } catch {
475
- // already exited between identity check and signal delivery
1181
+ // Re-inspect below; only a confirmed gone/replaced process settles cleanup.
476
1182
  }
1183
+ if (!delivered && inspect() !== "gone")
1184
+ throw new Error(`Detached SDK broker (pid ${pid}) could not be signaled under its pinned incarnation.`);
1185
+ return true;
477
1186
  };
478
- if (gone() || !matches()) return;
479
- // A reported trampoline has already detached this broker from the retained
480
- // child handle, so graceful shutdown cannot be observed or reaped reliably.
481
- // Force termination keeps discovery-timeout cleanup within its caller's
482
- // bounded deadline while the identity check still prevents PID reuse.
483
- signal("SIGKILL");
484
- await Promise.race([waitForProcessAbsence(pid), sleep(timing.killVerifyMs)]);
485
- if (!gone()) throw new Error(`Detached SDK broker (pid ${pid}) did not exit after SIGKILL during reap.`);
1187
+ const beforeTermination = inspect();
1188
+ if (beforeTermination === "gone") return;
1189
+ if (beforeTermination === "unknown") {
1190
+ throw new Error(`Detached SDK broker (pid ${pid}) cannot be reaped without a verified process incarnation.`);
1191
+ }
1192
+ if (!signal("SIGTERM")) return;
1193
+ if (await awaitGone(timing.gracefulMs)) return;
1194
+ if (!signal("SIGKILL")) return;
1195
+ if (await awaitGone(timing.killVerifyMs)) return;
1196
+ throw new Error(`Detached SDK broker (pid ${pid}) did not exit after SIGKILL during reap.`);
486
1197
  }
487
1198
 
488
- async function waitForProcessAbsence(pid: number): Promise<void> {
489
- while (observeProcessIncarnation(pid).status !== "absent") await Bun.sleep(10);
1199
+ /** Reap a failed launch through its pending trampoline lease or retained broker handle. */
1200
+ export async function reapFailedBrokerLaunch(
1201
+ launch: Pick<BrokerLaunchResult, "process" | "realBrokerPid" | "realBrokerIncarnation" | "handoff">,
1202
+ ): Promise<void> {
1203
+ if (launch.handoff) {
1204
+ await launch.handoff.terminate();
1205
+ return;
1206
+ }
1207
+ if (launch.realBrokerPid === undefined) return;
1208
+ if (launch.realBrokerPid === launch.process.pid) {
1209
+ await reapSpawnedBroker(launch.process, launch.realBrokerPid, launch.realBrokerIncarnation);
1210
+ return;
1211
+ }
1212
+ await reapDetachedBrokerPid(launch.realBrokerPid, launch.realBrokerIncarnation);
1213
+ }
1214
+
1215
+ /** Reap the direct broker child held by the startup trampoline before publication. */
1216
+ export async function reapBrokerTrampolineChild(
1217
+ child: ChildProcess,
1218
+ brokerPid: number | undefined,
1219
+ brokerIncarnation?: string,
1220
+ ): Promise<void> {
1221
+ await reapSpawnedBroker(child, brokerPid, brokerIncarnation);
490
1222
  }
491
1223
 
492
1224
  function registerBrokerOwner(
493
1225
  agentDir: string,
494
1226
  child: ChildProcess,
1227
+ realBrokerPid: number | undefined,
1228
+ brokerIncarnation: string | undefined,
495
1229
  timing: ReapTiming = DEFAULT_REAP_TIMING,
1230
+ launchHandoff?: BrokerLaunchHandoff,
496
1231
  ): BrokerOwner {
497
- let targetPid = child.pid;
498
- let targetIncarnation = child.pid === undefined ? undefined : brokerProcessIncarnation(child.pid);
1232
+ // Windows retains the hop process handle; POSIX retains the trampoline until publication
1233
+ // when requested. `realBrokerPid` and incarnation always identify the broker itself.
1234
+ const expectedBrokerPid = realBrokerPid ?? child.pid;
1235
+ const expectedBrokerIncarnation = brokerIncarnation;
1236
+ let handoff = launchHandoff;
1237
+ let publishedDiscovery: BrokerDiscovery | undefined;
499
1238
  let state: "starting" | "ready" | "cleanup-unverified" = "starting";
500
- const matches = (discovery: BrokerDiscovery | null): boolean =>
501
- Boolean(
502
- discovery &&
503
- targetPid !== undefined &&
504
- targetIncarnation !== undefined &&
505
- discovery.pid === targetPid &&
506
- discovery.incarnation === targetIncarnation,
1239
+ const matches = (discovery: BrokerDiscovery | null): boolean => {
1240
+ if (!discovery) return false;
1241
+ // Match discovery to the exact broker identity captured by the launch protocol.
1242
+ return Boolean(
1243
+ expectedBrokerPid !== undefined &&
1244
+ expectedBrokerIncarnation &&
1245
+ discovery.pid === expectedBrokerPid &&
1246
+ discovery.incarnation === expectedBrokerIncarnation,
507
1247
  );
508
- const owner: BrokerOwner = {
509
- async stop(): Promise<void> {
1248
+ };
1249
+ let stopTask: Promise<void> | undefined;
1250
+ const stop = (): Promise<void> => {
1251
+ if (stopTask) return stopTask;
1252
+ const task = (async () => {
510
1253
  try {
511
- if (targetPid !== undefined && targetIncarnation !== undefined && targetPid !== child.pid)
512
- await reapSpawnedBrokerIdentity(targetPid, targetIncarnation, timing);
513
- else await reapSpawnedBroker(child, timing);
1254
+ if (handoff) {
1255
+ await handoff.terminate();
1256
+ handoff = undefined;
1257
+ } else if (publishedDiscovery) {
1258
+ try {
1259
+ await shutdownBrokerViaRpc(
1260
+ publishedDiscovery,
1261
+ Date.now() +
1262
+ Math.max(BROKER_DISCOVERY_BUDGET.staleRetirementMs, timing.gracefulMs + timing.killVerifyMs),
1263
+ agentDir,
1264
+ );
1265
+ } catch (error) {
1266
+ if (process.platform === "darwin" && expectedBrokerPid !== child.pid) throw error;
1267
+ await reapSpawnedBroker(child, expectedBrokerPid, expectedBrokerIncarnation, timing);
1268
+ }
1269
+ } else {
1270
+ await reapSpawnedBroker(child, expectedBrokerPid, expectedBrokerIncarnation, timing);
1271
+ }
514
1272
  } catch (error) {
515
1273
  state = "cleanup-unverified";
516
1274
  throw error;
517
1275
  }
518
1276
  if (owners.get(agentDir) === owner) owners.delete(agentDir);
519
- },
1277
+ })();
1278
+ stopTask = task;
1279
+ void task.catch(() => {
1280
+ if (stopTask === task) stopTask = undefined;
1281
+ });
1282
+ return task;
1283
+ };
1284
+ const owner: BrokerOwner = {
1285
+ stop,
520
1286
  canReuse(discovery): boolean {
521
1287
  return state === "ready" && matches(discovery);
522
1288
  },
523
- markReady(discovery): boolean {
1289
+ async markReady(discovery): Promise<boolean> {
524
1290
  if (!matches(discovery)) return false;
1291
+ if (handoff) {
1292
+ await handoff.release();
1293
+ handoff = undefined;
1294
+ }
1295
+ publishedDiscovery = discovery;
525
1296
  state = "ready";
526
1297
  return true;
527
1298
  },
528
- setTarget(pid, incarnation): void {
529
- targetPid = pid;
530
- targetIncarnation = incarnation;
531
- },
532
1299
  };
533
1300
  owners.set(agentDir, owner);
534
1301
  return owner;
@@ -556,6 +1323,7 @@ function brokerSpawnEnvironment(command: SdkInternalSpawnCommand, override?: Nod
556
1323
  delete environment.PI_COMPILED;
557
1324
  delete environment.GJC_COMPILED;
558
1325
  }
1326
+ scrubUntrustedBrokerTestSignalDirectory(environment);
559
1327
  return environment;
560
1328
  }
561
1329
 
@@ -585,39 +1353,113 @@ export async function closeBrokerClientBeforeDeadline(
585
1353
  await Promise.race([close, ensureBrokerTiming.sleep(remainingMs)]);
586
1354
  }
587
1355
 
1356
+ function brokerIdentityIsGone(discovery: BrokerDiscovery): boolean {
1357
+ const observation = observeProcessIncarnation(discovery.pid);
1358
+ return (
1359
+ observation.status === "absent" ||
1360
+ (observation.status === "present" && observation.incarnation !== discovery.incarnation)
1361
+ );
1362
+ }
1363
+
1364
+ async function waitForBrokerIdentityGone(discovery: BrokerDiscovery, deadline: number): Promise<void> {
1365
+ for (;;) {
1366
+ if (brokerIdentityIsGone(discovery)) return;
1367
+ const remainingMs = deadline - ensureBrokerTiming.now();
1368
+ if (remainingMs <= 0) throw new Error(`SDK broker ${discovery.pid} did not exit after authenticated shutdown.`);
1369
+ await ensureBrokerTiming.sleep(Math.min(STALE_BROKER_POLL_MS, remainingMs));
1370
+ }
1371
+ }
1372
+
1373
+ async function waitForBrokerDiscoveryOwnerGone(
1374
+ agentDir: string,
1375
+ discovery: BrokerDiscovery,
1376
+ deadline: number,
1377
+ ): Promise<void> {
1378
+ for (;;) {
1379
+ // Heartbeat expiry is not proof that this owner removed its publication.
1380
+ const current = await readBrokerDiscoveryBeforeDeadline(agentDir, Number.POSITIVE_INFINITY, deadline);
1381
+ if (!current || !sameBrokerOwner(current, discovery)) return;
1382
+ const remainingMs = deadline - ensureBrokerTiming.now();
1383
+ if (remainingMs <= 0) throw new Error(`SDK broker ${discovery.pid} retained discovery during retirement.`);
1384
+ await ensureBrokerTiming.sleep(Math.min(STALE_BROKER_POLL_MS, remainingMs));
1385
+ }
1386
+ }
1387
+
1388
+ async function shutdownPublishedBrokerViaRpc(
1389
+ agentDir: string,
1390
+ discovery: BrokerDiscovery,
1391
+ deadline: number,
1392
+ ): Promise<void> {
1393
+ if (brokerIdentityIsGone(discovery)) return;
1394
+ const current = await readBrokerDiscovery(agentDir);
1395
+ if (!current || !sameBrokerOwner(current, discovery)) {
1396
+ if (discovery.pid === process.pid) await waitForBrokerDiscoveryOwnerGone(agentDir, discovery, deadline);
1397
+ else await waitForBrokerIdentityGone(discovery, deadline);
1398
+ return;
1399
+ }
1400
+ await shutdownBrokerViaRpc(current, deadline, agentDir);
1401
+ }
1402
+
1403
+ /** Stop a broker whose authenticated discovery was retained by its local owner. */
1404
+ async function shutdownBrokerViaRpc(discovery: BrokerDiscovery, deadline: number, agentDir: string): Promise<void> {
1405
+ if (brokerIdentityIsGone(discovery)) return;
1406
+ const remainingMs = deadline - ensureBrokerTiming.now();
1407
+ if (remainingMs <= 0) throw new Error("SDK broker shutdown deadline elapsed before RPC connection.");
1408
+ const client = await SdkClient.connect(discovery.url, discovery.token, {
1409
+ timeoutMs: Math.max(1, Math.min(2_000, remainingMs)),
1410
+ });
1411
+ try {
1412
+ await client.global("broker.shutdown", {}, { timeoutMs: Math.max(1, deadline - ensureBrokerTiming.now()) });
1413
+ } finally {
1414
+ await closeBrokerClientBeforeDeadline(client, deadline).catch(() => undefined);
1415
+ }
1416
+ if (discovery.pid === process.pid) await waitForBrokerDiscoveryOwnerGone(agentDir, discovery, deadline);
1417
+ else await waitForBrokerIdentityGone(discovery, deadline);
1418
+ }
1419
+
588
1420
  async function retireUnusableBroker(
589
1421
  stale: BrokerDiscovery,
590
1422
  settings: EnsureBrokerSettings,
591
1423
  outerDeadline = Number.POSITIVE_INFINITY,
592
1424
  ): Promise<void> {
593
1425
  const deadline = Math.min(outerDeadline, ensureBrokerTiming.now() + BROKER_DISCOVERY_BUDGET.staleRetirementMs);
594
- let shutdownSucceeded = false;
595
- try {
596
- const current = await readBrokerDiscoveryBeforeDeadline(settings.agentDir, settings.heartbeatTtlMs, deadline);
597
- if (!current || !sameBrokerOwner(current, stale)) return;
598
- const client = await SdkClient.connect(current.url, current.token, { timeoutMs: 2_000, deadline });
599
- try {
600
- await client.global("broker.shutdown", {});
601
- shutdownSucceeded = true;
602
- } finally {
603
- await closeBrokerClientBeforeDeadline(client, deadline).catch(() => undefined);
1426
+ const current = await readBrokerDiscoveryBeforeDeadline(settings.agentDir, settings.heartbeatTtlMs, deadline);
1427
+ if (!current || !sameBrokerOwner(current, stale)) {
1428
+ if (stale.pid === process.pid) {
1429
+ await waitForBrokerDiscoveryOwnerGone(settings.agentDir, stale, deadline);
1430
+ return;
604
1431
  }
605
- } catch {}
606
- if (!shutdownSucceeded) {
607
- try {
608
- if (brokerProcessIncarnation(stale.pid) === stale.incarnation) {
609
- try {
610
- process.kill(stale.pid, "SIGTERM");
611
- } catch {}
612
- }
613
- } catch {}
1432
+ await reapDetachedBrokerPid(stale.pid, stale.incarnation);
1433
+ await waitForBrokerIdentityGone(stale, deadline);
1434
+ return;
614
1435
  }
615
- // Wait for the stale identity to disappear (owner-fenced: pid+incarnation).
616
- while (ensureBrokerTiming.now() < deadline) {
617
- const current = await readBrokerDiscoveryBeforeDeadline(settings.agentDir, settings.heartbeatTtlMs, deadline);
618
- if (!sameBrokerOwner(current, stale)) break;
619
- await ensureBrokerTiming.sleep(STALE_BROKER_POLL_MS);
1436
+ try {
1437
+ await shutdownPublishedBrokerViaRpc(settings.agentDir, current, deadline);
1438
+ return;
1439
+ } catch (rpcError) {
1440
+ if (stale.pid !== process.pid) {
1441
+ try {
1442
+ await reapDetachedBrokerPid(stale.pid, stale.incarnation);
1443
+ } catch (cleanupError) {
1444
+ throw new AggregateError(
1445
+ [rpcError, cleanupError],
1446
+ `SDK broker ${stale.pid} shutdown and identity-bound cleanup both failed.`,
1447
+ );
1448
+ }
1449
+ } else {
1450
+ try {
1451
+ await waitForBrokerDiscoveryOwnerGone(settings.agentDir, stale, deadline);
1452
+ } catch (retirementError) {
1453
+ throw new AggregateError(
1454
+ [rpcError, retirementError],
1455
+ `SDK broker ${stale.pid} shutdown RPC and discovery retirement both failed.`,
1456
+ );
1457
+ }
1458
+ return;
1459
+ }
1460
+ // If pid === process.pid, fall through to wait on identity
620
1461
  }
1462
+ await waitForBrokerIdentityGone(stale, deadline);
621
1463
  }
622
1464
 
623
1465
  /** Reconcile an observed broker generation before entering a startup critical section. */
@@ -675,7 +1517,11 @@ function createFixtureLease(owner: BrokerOwner, child: ChildProcess): ExactFixtu
675
1517
  return createFixtureLeaseFromChild(child, () => owner.stop());
676
1518
  }
677
1519
 
678
- async function ensureBrokerOnce(settings: EnsureBrokerSettings, initiator: EnsureInitiator): Promise<EnsureOutcome> {
1520
+ async function ensureBrokerOnce(
1521
+ settings: EnsureBrokerSettings,
1522
+ initiator: EnsureInitiator,
1523
+ retriedStartupLockUnavailable = false,
1524
+ ): Promise<EnsureOutcome> {
679
1525
  const initialDiscoveryDeadline =
680
1526
  ensureBrokerTiming.now() + (initiator === "fixture-lease" ? FIXTURE_DISCOVERY_TIMEOUT_MS : DISCOVERY_TIMEOUT_MS);
681
1527
  const priorOwner = owners.get(settings.agentDir);
@@ -716,6 +1562,7 @@ async function ensureBrokerOnce(settings: EnsureBrokerSettings, initiator: Ensur
716
1562
  // Fixture leases always spawn their own.
717
1563
  let spawnLog: BrokerSpawnLog | undefined;
718
1564
  const releaseSpawnLock = initiator === "fixture-lease" ? async () => {} : await acquireSpawnLock(settings.agentDir);
1565
+ let spawnLockReleased = false;
719
1566
  try {
720
1567
  const deadline =
721
1568
  ensureBrokerTiming.now() +
@@ -731,176 +1578,200 @@ async function ensureBrokerOnce(settings: EnsureBrokerSettings, initiator: Ensur
731
1578
  // that finished between our first read and the lock acquisition.
732
1579
  const discoveredUnderLock = await reconcileBrokerGenerationForStartup(settings, deadline);
733
1580
  if (discoveredUnderLock) return { kind: "external-discovery", discovery: discoveredUnderLock };
734
- const command = resolveSdkInternalSpawnCommand(
735
- process.platform === "win32" || initiator === "fixture-lease"
736
- ? "broker-internal"
737
- : "broker-trampoline-internal",
738
- );
1581
+ const command = resolveSdkInternalSpawnCommand("broker-internal");
739
1582
  spawnLog = await openBrokerSpawnLog(settings.agentDir);
740
1583
  // A stale marker must never be misattributed to this spawn; clear it first.
741
1584
  await clearBrokerStartupExitRecord(settings.agentDir);
742
1585
  await clearBrokerStartupFailureMarker(settings.agentDir);
743
1586
  const childSpawnedAt = Date.now();
744
- const child = spawn(command.file, [...command.args, "--agent-dir", settings.agentDir], {
745
- detached: true,
746
- stdio: ["ignore", process.platform === "win32" ? "ignore" : "pipe", spawnLog ? spawnLog.handle.fd : "ignore"],
747
- env: brokerSpawnEnvironment(command, settings.env),
748
- ...(command.kind === "bun-source" ? { cwd: command.cwd } : {}),
749
- });
750
- const isTrampoline = process.platform !== "win32" && initiator !== "fixture-lease";
1587
+ const brokerArgs = [...command.args, "--agent-dir", settings.agentDir];
1588
+ const env = brokerSpawnEnvironment(command, settings.env);
1589
+ let spawnResult: BrokerSpawnResult;
751
1590
  let spawnError: Error | undefined;
752
- let childExited = false;
753
- let childExitCode: number | null = null;
754
- let childSignalCode: NodeJS.Signals | null = null;
755
- let brokerDeathObserved = false;
756
- child.once("error", error => {
757
- spawnError = error;
758
- });
759
- child.once("close", (code, signal) => {
760
- childExited = true;
761
- childExitCode = code;
762
- childSignalCode = signal;
763
- });
764
- let trampolineReported = !isTrampoline;
765
- let spawnedBrokerPid = isTrampoline ? undefined : child.pid;
766
- let spawnedBrokerIncarnation =
767
- isTrampoline || child.pid === undefined ? undefined : brokerProcessIncarnation(child.pid);
768
- const owner = registerBrokerOwner(settings.agentDir, child);
769
- if (isTrampoline && child.stdout) {
770
- let output = "";
771
- child.stdout.on("data", chunk => {
772
- output += Buffer.from(chunk).toString("utf8");
773
- const newline = output.indexOf("\n");
774
- if (newline < 0) return;
775
- const line = output.slice(0, newline).trim();
776
- const [rawPid, rawIncarnation] = line?.split("\t") ?? [];
777
- const pid = rawPid ? Number(rawPid) : NaN;
778
- if (!Number.isSafeInteger(pid) || pid <= 0) return;
779
- if (isProcessIncarnation(rawIncarnation)) {
780
- const incarnation = rawIncarnation;
781
- trampolineReported = true;
782
- spawnedBrokerPid = pid;
783
- spawnedBrokerIncarnation = incarnation;
784
- owner.setTarget(pid, incarnation);
785
- }
1591
+ const launchMode = resolveBrokerLaunchMode(process.platform, initiator);
1592
+ if (launchMode === "windows-hop") {
1593
+ const launched = await launchBrokerViaHop(
1594
+ {
1595
+ command: { file: command.file, args: brokerArgs },
1596
+ ...(command.kind === "bun-source" ? { cwd: command.cwd } : {}),
1597
+ ...(spawnLog ? { stderrLogPath: spawnLog.path } : {}),
1598
+ },
1599
+ {
1600
+ env,
1601
+ cwd: command.kind === "bun-source" ? command.cwd : undefined,
1602
+ timeoutMs: Math.max(0, deadline - ensureBrokerTiming.now()),
1603
+ },
1604
+ );
1605
+ spawnResult = {
1606
+ process: launched.process,
1607
+ realBrokerPid: launched.realBrokerPid,
1608
+ realBrokerIncarnation: launched.realBrokerIncarnation,
1609
+ };
1610
+ spawnError = launched.error;
1611
+ } else if (launchMode === "posix-trampoline" || launchMode === "darwin-trampoline") {
1612
+ const launched = await launchBrokerViaPosixTrampoline(settings.agentDir, {
1613
+ env,
1614
+ ...(command.kind === "bun-source" ? { cwd: command.cwd } : {}),
1615
+ ...(spawnLog ? { stderrFd: spawnLog.handle.fd } : {}),
1616
+ timeoutMs: Math.max(0, deadline - ensureBrokerTiming.now()),
1617
+ retainChildUntilPublication: launchMode === "darwin-trampoline",
1618
+ });
1619
+ spawnResult = {
1620
+ process: launched.process,
1621
+ realBrokerPid: launched.realBrokerPid,
1622
+ realBrokerIncarnation: launched.realBrokerIncarnation,
1623
+ handoff: launched.handoff,
1624
+ };
1625
+ spawnError = launched.error;
1626
+ } else {
1627
+ spawnResult = spawnBrokerDirect(command.file, brokerArgs, {
1628
+ stdioFd: spawnLog?.handle.fd,
1629
+ env,
1630
+ cwd: command.kind === "bun-source" ? command.cwd : undefined,
1631
+ detached: process.platform !== "win32",
1632
+ });
1633
+ spawnResult.process.once("error", error => {
1634
+ spawnError = error;
786
1635
  });
787
1636
  }
788
- child.unref();
1637
+
1638
+ const child = spawnResult.process;
1639
+ const realBrokerPid = spawnResult.realBrokerPid;
1640
+ let childIncarnation =
1641
+ spawnResult.realBrokerIncarnation ??
1642
+ (launchMode === "direct" && realBrokerPid !== undefined ? brokerProcessIncarnation(realBrokerPid) : undefined);
1643
+ let owner = registerBrokerOwner(
1644
+ settings.agentDir,
1645
+ child,
1646
+ realBrokerPid,
1647
+ childIncarnation,
1648
+ DEFAULT_REAP_TIMING,
1649
+ spawnResult.handoff,
1650
+ );
1651
+ if (!spawnResult.handoff) child.unref();
789
1652
  // The child holds its own duplicate of the descriptor. Failure to close the
790
1653
  // parent's diagnostic handle must not discard exact ownership of a live child.
791
1654
  await spawnLog?.handle.close().catch(() => undefined);
792
1655
  let discoveryError: unknown;
793
- while (ensureBrokerTiming.now() < deadline) {
794
- if (
795
- spawnError ||
796
- (isTrampoline && childExited && (!trampolineReported || childExitCode !== 0 || childSignalCode !== null)) ||
797
- (!isTrampoline && process.platform === "win32" && (child.exitCode !== null || child.signalCode !== null))
798
- )
799
- break;
800
- if (
801
- isTrampoline &&
802
- trampolineReported &&
803
- spawnedBrokerPid !== undefined &&
804
- spawnedBrokerIncarnation !== undefined
805
- ) {
806
- const observed = observeProcessIncarnation(spawnedBrokerPid);
807
- if (observed.status === "present" && observed.incarnation !== spawnedBrokerIncarnation) {
808
- brokerDeathObserved = true;
809
- break;
810
- }
811
- if (observed.status === "absent") {
812
- brokerDeathObserved = true;
813
- break;
814
- }
815
- }
816
- // The trampoline's stdout line identifies the real broker. Do not read
817
- // discovery until that line has been parsed, or a marker from the
818
- // trampoline could be trusted as the broker's own startup evidence.
819
- if (isTrampoline && !trampolineReported) {
820
- await ensureBrokerTiming.sleep(50);
821
- continue;
822
- }
1656
+ let brokerExitedBeforeDiscovery = false;
1657
+ const observeDiscovery = async (): Promise<EnsureOutcome | undefined> => {
823
1658
  try {
824
1659
  const discovered = await readBrokerDiscoveryBeforeDeadline(
825
1660
  settings.agentDir,
826
1661
  settings.heartbeatTtlMs,
827
1662
  deadline,
828
1663
  );
829
- if (discovered) {
830
- if (!(await isBrokerReusable(discovered))) {
831
- await ensureBrokerTiming.sleep(50);
832
- continue;
833
- }
834
- if (isTrampoline && !childExited) {
835
- await ensureBrokerTiming.sleep(50);
836
- continue;
837
- }
838
- if (isTrampoline && spawnedBrokerPid !== undefined && spawnedBrokerIncarnation !== undefined) {
839
- const observed = observeProcessIncarnation(spawnedBrokerPid);
840
- if (
841
- observed.status === "absent" ||
842
- (observed.status === "present" && observed.incarnation !== spawnedBrokerIncarnation)
843
- ) {
844
- brokerDeathObserved = true;
845
- break;
846
- }
1664
+ if (!discovered || !(await isBrokerReusable(discovered))) return undefined;
1665
+ if (
1666
+ childIncarnation === undefined &&
1667
+ realBrokerPid !== undefined &&
1668
+ realBrokerPid === child.pid &&
1669
+ discovered.pid === realBrokerPid
1670
+ ) {
1671
+ const verifiedIncarnation = brokerProcessIncarnation(realBrokerPid);
1672
+ if (verifiedIncarnation === discovered.incarnation) {
1673
+ childIncarnation = verifiedIncarnation;
1674
+ owner = registerBrokerOwner(
1675
+ settings.agentDir,
1676
+ child,
1677
+ realBrokerPid,
1678
+ childIncarnation,
1679
+ DEFAULT_REAP_TIMING,
1680
+ spawnResult.handoff,
1681
+ );
847
1682
  }
848
- if (owner.markReady(discovered)) {
849
- return initiator === "fixture-lease"
850
- ? { kind: "local-started-fixture", discovery: discovered, owner, child }
851
- : { kind: "local-started-discovery", discovery: discovered };
852
- }
853
- await owner.stop();
854
- return { kind: "external-discovery", discovery: discovered };
855
1683
  }
1684
+ if (await owner.markReady(discovered)) {
1685
+ return initiator === "fixture-lease"
1686
+ ? { kind: "local-started-fixture", discovery: discovered, owner, child }
1687
+ : { kind: "local-started-discovery", discovery: discovered };
1688
+ }
1689
+ await owner.stop();
1690
+ return { kind: "external-discovery", discovery: discovered };
856
1691
  } catch (error) {
857
1692
  discoveryError = error;
1693
+ return undefined;
858
1694
  }
859
- await ensureBrokerTiming.sleep(50);
860
- }
861
- const exitedBeforeDiscovery =
862
- (isTrampoline &&
863
- (!trampolineReported ||
864
- (childExited && (childExitCode !== 0 || childSignalCode !== null)) ||
865
- brokerDeathObserved)) ||
866
- (!isTrampoline && process.platform === "win32" && (child.exitCode !== null || child.signalCode !== null));
867
- if (!isTrampoline && exitedBeforeDiscovery && childExitCode === 0) {
868
- // A clean exit means another broker won the ownership lock (two ACP
869
- // processes racing a cold broker state, e.g. a provider probe and an
870
- // agent launch). The winner may publish its discovery right after our
871
- // last poll; reuse it instead of failing the caller. Transient discovery
872
- // read failures fall through to the common cleanup + failure path below.
873
- try {
874
- for (let retry = 0; retry < 20; retry++) {
875
- const winner = await readBrokerDiscoveryBeforeDeadline(
876
- settings.agentDir,
877
- settings.heartbeatTtlMs,
878
- deadline,
879
- );
880
- if (winner && (await isBrokerReusable(winner))) {
881
- await owner.stop();
882
- return { kind: "external-discovery", discovery: winner };
1695
+ };
1696
+ while (ensureBrokerTiming.now() < deadline) {
1697
+ const failedSpawn =
1698
+ spawnError || child.signalCode !== null || (child.exitCode !== null && child.exitCode !== 0);
1699
+ if (failedSpawn) {
1700
+ const discovered = await observeDiscovery();
1701
+ if (discovered) return discovered;
1702
+ brokerExitedBeforeDiscovery = true;
1703
+ break;
1704
+ }
1705
+ let brokerObservation: ProcessIncarnationObservation | undefined;
1706
+ if (realBrokerPid !== undefined) {
1707
+ brokerObservation = observeProcessIncarnation(realBrokerPid);
1708
+ if (brokerObservation.status === "absent") {
1709
+ const discovered = await observeDiscovery();
1710
+ if (discovered) return discovered;
1711
+ brokerExitedBeforeDiscovery = true;
1712
+ break;
1713
+ }
1714
+ if (
1715
+ brokerObservation.status === "present" &&
1716
+ childIncarnation !== undefined &&
1717
+ brokerObservation.incarnation !== childIncarnation
1718
+ ) {
1719
+ const discovered = await observeDiscovery();
1720
+ if (discovered) return discovered;
1721
+ brokerExitedBeforeDiscovery = true;
1722
+ break;
1723
+ }
1724
+ if (
1725
+ brokerObservation.status === "present" &&
1726
+ childIncarnation === undefined &&
1727
+ realBrokerPid === child.pid
1728
+ ) {
1729
+ childIncarnation = brokerObservation.incarnation;
1730
+ owner = registerBrokerOwner(settings.agentDir, child, realBrokerPid, childIncarnation);
1731
+ }
1732
+ const startupExitRecord = await readBrokerStartupExitRecord(settings.agentDir);
1733
+ if (startupExitRecord?.pid === realBrokerPid && startupExitRecord.writtenAt >= childSpawnedAt) {
1734
+ const discovered = await observeDiscovery();
1735
+ if (discovered) return discovered;
1736
+ brokerExitedBeforeDiscovery = true;
1737
+ break;
1738
+ }
1739
+ if (brokerObservation.status === "present" && childIncarnation === brokerObservation.incarnation) {
1740
+ const marker = await readBrokerStartupFailureMarker(settings.agentDir);
1741
+ if (
1742
+ marker?.pid === realBrokerPid &&
1743
+ marker?.incarnation === childIncarnation &&
1744
+ marker.writtenAt >= childSpawnedAt
1745
+ ) {
1746
+ const discovered = await observeDiscovery();
1747
+ if (discovered) return discovered;
1748
+ brokerExitedBeforeDiscovery = true;
1749
+ break;
883
1750
  }
884
- await ensureBrokerTiming.sleep(50);
885
1751
  }
886
- } catch {
887
- // fall through to cleanup + failure
888
1752
  }
1753
+ const discovered = await observeDiscovery();
1754
+ if (discovered) return discovered;
1755
+ await ensureBrokerTiming.sleep(50);
889
1756
  }
1757
+ const exitedBeforeDiscovery = brokerExitedBeforeDiscovery;
890
1758
  const marker = await readBrokerStartupFailureMarker(settings.agentDir);
891
1759
  const startupExitRecord = await readBrokerStartupExitRecord(settings.agentDir);
1760
+ // Hops and committed trampolines may exit before the parent finishes polling.
1761
+ // Failure records are attributed to the real broker pid, not the launcher.
892
1762
  const trustedMarker =
893
1763
  marker &&
894
- spawnedBrokerPid !== undefined &&
895
- spawnedBrokerIncarnation !== undefined &&
896
- marker.pid === spawnedBrokerPid &&
897
- marker.incarnation === spawnedBrokerIncarnation
1764
+ childIncarnation !== undefined &&
1765
+ realBrokerPid !== undefined &&
1766
+ marker.pid === realBrokerPid &&
1767
+ marker.incarnation === childIncarnation &&
1768
+ marker.writtenAt >= childSpawnedAt
898
1769
  ? marker
899
1770
  : undefined;
900
1771
  const trustedStartupExitRecord =
901
1772
  startupExitRecord &&
902
- spawnedBrokerPid !== undefined &&
903
- startupExitRecord.pid === spawnedBrokerPid &&
1773
+ realBrokerPid !== undefined &&
1774
+ startupExitRecord.pid === realBrokerPid &&
904
1775
  startupExitRecord.writtenAt >= childSpawnedAt
905
1776
  ? startupExitRecord
906
1777
  : undefined;
@@ -909,7 +1780,7 @@ async function ensureBrokerOnce(settings: EnsureBrokerSettings, initiator: Ensur
909
1780
  exitedBeforeDiscovery &&
910
1781
  (trustedMarker?.reason.startsWith(`Failed to acquire lock for ${startupLockPath} after `) === true ||
911
1782
  brokerStartupFailureCleanupTargetsLock(trustedMarker, `${startupLockPath}.lock`));
912
- if (!startupFenceContention && exitedBeforeDiscovery && child.exitCode !== 0) {
1783
+ if (!startupFenceContention && exitedBeforeDiscovery) {
913
1784
  try {
914
1785
  startupFenceContention = (await fs.stat(`${startupLockPath}.lock`)).isDirectory();
915
1786
  } catch {
@@ -951,23 +1822,10 @@ async function ensureBrokerOnce(settings: EnsureBrokerSettings, initiator: Ensur
951
1822
  // rejects that marker instead of misattributing a foreign failure to this
952
1823
  // spawn's caller.
953
1824
  const failure = spawnError
954
- ? new Error(`Failed to spawn detached SDK broker: ${spawnError.message}`)
1825
+ ? brokerSpawnFailureError(spawnError)
955
1826
  : exitedBeforeDiscovery
956
1827
  ? new BrokerStartupError({
957
- exitCode: isTrampoline
958
- ? (trustedStartupExitRecord?.exitCode ??
959
- trustedMarker?.exitCode ??
960
- childExitCode ??
961
- child.exitCode ??
962
- null)
963
- : (childExitCode ?? child.exitCode ?? trustedStartupExitRecord?.exitCode ?? null),
964
- signal: isTrampoline
965
- ? (trustedStartupExitRecord?.signal ??
966
- trustedMarker?.signal ??
967
- childSignalCode ??
968
- child.signalCode ??
969
- null)
970
- : (childSignalCode ?? child.signalCode ?? trustedStartupExitRecord?.signal ?? null),
1828
+ ...brokerStartupExitStatus(child.exitCode, child.signalCode, trustedStartupExitRecord, trustedMarker),
971
1829
  reason:
972
1830
  brokerStartupExitReason(trustedStartupExitRecord) ?? brokerStartupFailureReason(trustedMarker),
973
1831
  stderrExcerpt: spawnLogTail.length > 0 ? spawnLogTail : undefined,
@@ -983,10 +1841,20 @@ async function ensureBrokerOnce(settings: EnsureBrokerSettings, initiator: Ensur
983
1841
  "SDK broker discovery and spawned broker cleanup both failed.",
984
1842
  );
985
1843
  }
1844
+ if (
1845
+ !retriedStartupLockUnavailable &&
1846
+ initiator === "discovery" &&
1847
+ trustedStartupExitRecord?.reason === "startup-lock-unavailable"
1848
+ ) {
1849
+ await releaseSpawnLock();
1850
+ spawnLockReleased = true;
1851
+ await ensureBrokerTiming.sleep(SPAWN_LOCK_RETRY_DELAY_MS);
1852
+ return ensureBrokerOnce(settings, initiator, true);
1853
+ }
986
1854
  throw failure;
987
1855
  } finally {
988
1856
  if (spawnLog) await removeBrokerSpawnLog(spawnLog.path);
989
- await releaseSpawnLock();
1857
+ if (!spawnLockReleased) await releaseSpawnLock();
990
1858
  }
991
1859
  }
992
1860
 
@@ -1052,12 +1920,16 @@ export function startFixtureBrokerWithLeaseForTest(settings: EnsureBrokerSetting
1052
1920
  * Test-only launch surface for topology fixtures. It accepts an already-resolved
1053
1921
  * command and retains the exact spawned child; no production selection path
1054
1922
  * reaches this function.
1923
+ *
1924
+ * Note: This function does NOT use the Windows broker hop. Fixtures are test-only
1925
+ * and do not require Windows process-tree detachment; the fixture needs to retain
1926
+ * exact ownership of the actual broker process to control its stdio[3] control pipe.
1055
1927
  */
1056
1928
  export function startFixtureBrokerCommandWithLeaseForTest(command: FixtureBrokerCommand): StartedFixtureBrokerCommand {
1057
1929
  if (!command.file || !Array.isArray(command.args)) throw new Error("Invalid fixture broker command.");
1058
- const child = spawn(command.file, [...command.args], {
1930
+ const child = spawn(command.file, command.args, {
1059
1931
  cwd: command.cwd,
1060
- detached: true,
1932
+ detached: process.platform !== "win32",
1061
1933
  stdio: ["ignore", "ignore", "ignore", "pipe"],
1062
1934
  env: command.env,
1063
1935
  });
@@ -1107,8 +1979,48 @@ export function brokerOwnerIdentityMatchesForTest(
1107
1979
  return sameBrokerOwner(left, right);
1108
1980
  }
1109
1981
  /** Test hook: drives the detached-broker reap on a controllable child surface. */
1110
- export function reapSpawnedBrokerForTest(child: ChildProcess, timing: ReapTiming = DEFAULT_REAP_TIMING): Promise<void> {
1111
- return reapSpawnedBroker(child, timing);
1982
+ export function reapSpawnedBrokerForTest(
1983
+ child: ChildProcess,
1984
+ realBrokerPid?: number,
1985
+ brokerIncarnation?: string,
1986
+ timing: ReapTiming = DEFAULT_REAP_TIMING,
1987
+ ): Promise<void> {
1988
+ return reapSpawnedBroker(child, realBrokerPid, brokerIncarnation, timing);
1989
+ }
1990
+ /** Test hook: verifies detached PID reaping fences every signal and poll by incarnation. */
1991
+ export function reapDetachedBrokerPidForTest(
1992
+ pid: number,
1993
+ brokerIncarnation: string | undefined,
1994
+ timing: ReapTiming = DEFAULT_REAP_TIMING,
1995
+ observe: (pid: number) => ProcessIncarnationObservation = observeProcessIncarnation,
1996
+ signalProcess: DetachedBrokerSignal = signalDetachedBrokerProcess,
1997
+ ): Promise<void> {
1998
+ return reapDetachedBrokerPid(pid, brokerIncarnation, timing, observe, signalProcess);
1999
+ }
2000
+ /** Test hook: verifies a pinned process handle matches before sending a signal. */
2001
+ export function signalPinnedBrokerProcessForTest(
2002
+ reference: PinnedBrokerProcess | null,
2003
+ expectedIncarnation: string,
2004
+ signal: NodeJS.Signals,
2005
+ ): boolean {
2006
+ return signalPinnedBrokerProcess(reference, expectedIncarnation, signal);
2007
+ }
2008
+ /** Test hook: verifies platforms without identity-bound signaling fail closed. */
2009
+ export function signalDetachedBrokerProcessForTest(
2010
+ pid: number,
2011
+ brokerIncarnation: string,
2012
+ signal: NodeJS.Signals,
2013
+ platform: NodeJS.Platform,
2014
+ ): boolean {
2015
+ return signalDetachedBrokerProcess(pid, brokerIncarnation, signal, platform);
2016
+ }
2017
+ /** Test hook: the signal directory accepted from this environment and snapshot. */
2018
+ export function trustedBrokerSignalDirectoryForTest(
2019
+ env: NodeJS.ProcessEnv,
2020
+ snapshot: ProjectEnvSnapshot | readonly ProjectEnvSnapshot[],
2021
+ ): string | undefined {
2022
+ const snapshots = Array.isArray(snapshot) ? snapshot : [snapshot];
2023
+ return trustedBrokerTestSignalDirectory(env, snapshots);
1112
2024
  }
1113
2025
  /** Test hook: resolves the complete broker environment without spawning. */
1114
2026
  export function brokerSpawnEnvironmentForTest(
@@ -1121,12 +2033,46 @@ export function brokerSpawnEnvironmentForTest(
1121
2033
  export function registerBrokerOwnerForTest(
1122
2034
  agentDir: string,
1123
2035
  child: ChildProcess,
2036
+ realBrokerPid?: number,
2037
+ brokerIncarnation?: string,
1124
2038
  timing: ReapTiming = DEFAULT_REAP_TIMING,
1125
2039
  ): BrokerOwner {
1126
- return registerBrokerOwner(agentDir, child, timing);
2040
+ return registerBrokerOwner(agentDir, child, realBrokerPid, brokerIncarnation, timing);
1127
2041
  }
1128
2042
 
1129
2043
  /** Test hook: exercises the same trusted-marker reason reconstruction used by ensureBroker. */
1130
2044
  export function brokerStartupFailureReasonForTest(marker: BrokerStartupFailureMarker | undefined): string {
1131
2045
  return brokerStartupFailureReason(marker);
1132
2046
  }
2047
+ /** Test hook: verifies typed hop errors survive ensureBroker's spawn-failure mapping. */
2048
+ export function brokerSpawnFailureErrorForTest(spawnError: Error): Error {
2049
+ return brokerSpawnFailureError(spawnError);
2050
+ }
2051
+ /** Test hook: preserves and reaps a verified broker reply captured at the hop deadline. */
2052
+ export function brokerHopTimeoutResultForTest(
2053
+ hop: ChildProcess,
2054
+ stdout: string,
2055
+ stderr: string,
2056
+ timeoutMs: number,
2057
+ launcherTerminated: boolean,
2058
+ ): Promise<BrokerLaunchResult> {
2059
+ return brokerHopTimeoutResult(hop, stdout, stderr, timeoutMs, launcherTerminated);
2060
+ }
2061
+ /** Test hook: preserves and reaps a verified POSIX trampoline reply at its deadline. */
2062
+ export function brokerTrampolineTimeoutResultForTest(
2063
+ trampoline: ChildProcess,
2064
+ stdout: string,
2065
+ timeoutMs: number,
2066
+ trampolineTerminated: boolean,
2067
+ ): Promise<BrokerLaunchResult> {
2068
+ return brokerTrampolineTimeoutResult(trampoline, stdout, timeoutMs, trampolineTerminated);
2069
+ }
2070
+ /** Test hook: verifies broker exit evidence takes precedence over the launcher's status. */
2071
+ export function brokerStartupExitStatusForTest(
2072
+ childExitCode: number | null,
2073
+ childSignal: NodeJS.Signals | null,
2074
+ startupExitRecord: Pick<BrokerStartupExitRecord, "exitCode" | "signal"> | undefined,
2075
+ startupFailureMarker: Pick<BrokerStartupFailureMarker, "exitCode" | "signal"> | undefined,
2076
+ ): { exitCode: number | null; signal: string | null } {
2077
+ return brokerStartupExitStatus(childExitCode, childSignal, startupExitRecord, startupFailureMarker);
2078
+ }