@gajae-code/coding-agent 0.14.2 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (345) hide show
  1. package/CHANGELOG.md +120 -1
  2. package/dist/types/async/job-manager.d.ts +4 -1
  3. package/dist/types/autoresearch/dashboard.d.ts +29 -4
  4. package/dist/types/autoresearch/data-context.d.ts +25 -0
  5. package/dist/types/autoresearch/git.d.ts +65 -7
  6. package/dist/types/autoresearch/harness.d.ts +58 -0
  7. package/dist/types/autoresearch/prompts.d.ts +51 -0
  8. package/dist/types/autoresearch/runs.d.ts +173 -0
  9. package/dist/types/autoresearch/types.d.ts +9 -139
  10. package/dist/types/capability/types.d.ts +20 -0
  11. package/dist/types/cli/accounts-cli.d.ts +1 -0
  12. package/dist/types/commands/{rlm.d.ts → autoresearch.d.ts} +2 -4
  13. package/dist/types/config/autorouting-contract.d.ts +146 -0
  14. package/dist/types/config/autorouting-generator.d.ts +40 -0
  15. package/dist/types/config/autorouting-tier-map.d.ts +16844 -0
  16. package/dist/types/config/autorouting.d.ts +35 -0
  17. package/dist/types/config/model-discovery-manager.d.ts +8 -1
  18. package/dist/types/config/model-registry.d.ts +30 -1
  19. package/dist/types/config/provider-selection-policy.d.ts +23 -0
  20. package/dist/types/config/settings-schema.d.ts +61 -3
  21. package/dist/types/config/settings.d.ts +3 -0
  22. package/dist/types/coordinator/contract.d.ts +1 -1
  23. package/dist/types/coordinator-mcp/durability.d.ts +29 -0
  24. package/dist/types/coordinator-mcp/event-webhook.d.ts +50 -0
  25. package/dist/types/coordinator-mcp/question-state.d.ts +1 -1
  26. package/dist/types/coordinator-mcp/server.d.ts +14 -0
  27. package/dist/types/crash/index-store.d.ts +3 -0
  28. package/dist/types/crash/record-loader.d.ts +2 -0
  29. package/dist/types/crash/upstream/relay.d.ts +5 -3
  30. package/dist/types/cursor.d.ts +12 -0
  31. package/dist/types/defaults/gjc-defaults.d.ts +35 -1
  32. package/dist/types/defaults/gjc-skills.generated.d.ts +1 -1
  33. package/dist/types/discovery/helpers.d.ts +1 -1
  34. package/dist/types/edit/index.d.ts +2 -1
  35. package/dist/types/eval/py/executor.d.ts +2 -0
  36. package/dist/types/extensibility/extensions/loader.d.ts +32 -0
  37. package/dist/types/extensibility/gjc-plugins/types.d.ts +1 -1
  38. package/dist/types/extensibility/skills.d.ts +2 -0
  39. package/dist/types/gjc-runtime/autoresearch-runtime.d.ts +194 -0
  40. package/dist/types/gjc-runtime/gc-runtime.d.ts +1 -1
  41. package/dist/types/gjc-runtime/psmux-detect.d.ts +1 -1
  42. package/dist/types/gjc-runtime/python-transcript.d.ts +19 -0
  43. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +2 -8
  44. package/dist/types/gjc-runtime/session-layout.d.ts +6 -3
  45. package/dist/types/gjc-runtime/state-renderer.d.ts +0 -22
  46. package/dist/types/gjc-runtime/state-schema.d.ts +7 -7
  47. package/dist/types/gjc-runtime/tmux-common.d.ts +2 -2
  48. package/dist/types/gjc-runtime/ultragoal-change-set.d.ts +38 -1
  49. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +2 -24
  50. package/dist/types/gjc-runtime/ultragoal-validation-policy.d.ts +73 -0
  51. package/dist/types/gjc-runtime/workflow-recovery-projection.d.ts +100 -0
  52. package/dist/types/hooks/skill-keywords.d.ts +1 -1
  53. package/dist/types/main.d.ts +0 -10
  54. package/dist/types/modes/acp/acp-agent.d.ts +13 -6
  55. package/dist/types/modes/components/model-selector.d.ts +14 -0
  56. package/dist/types/modes/components/skill-hud/render.d.ts +6 -1
  57. package/dist/types/modes/components/smart-routing-panel.d.ts +82 -0
  58. package/dist/types/modes/controllers/selector-controller.d.ts +14 -0
  59. package/dist/types/modes/interactive-mode.d.ts +3 -0
  60. package/dist/types/modes/types.d.ts +9 -0
  61. package/dist/types/rlm/artifacts.d.ts +4 -4
  62. package/dist/types/runtime/optional-runtime-services.d.ts +2 -1
  63. package/dist/types/runtime/workspace-tree-service.d.ts +5 -1
  64. package/dist/types/runtime-mcp/config.d.ts +6 -0
  65. package/dist/types/runtime-mcp/manager.d.ts +10 -0
  66. package/dist/types/sdk/acp/adapter.d.ts +0 -4
  67. package/dist/types/sdk/broker/broker.d.ts +1 -1
  68. package/dist/types/sdk/broker/lifecycle.d.ts +57 -2
  69. package/dist/types/sdk/broker/session-index.d.ts +15 -0
  70. package/dist/types/sdk/bus/chat-daemon-control.d.ts +6 -0
  71. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +4 -0
  72. package/dist/types/sdk/bus/index.d.ts +8 -0
  73. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +2 -0
  74. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +12 -2
  75. package/dist/types/sdk/client/client.d.ts +39 -4
  76. package/dist/types/sdk/host/host.d.ts +1 -0
  77. package/dist/types/sdk/host/internal-autorouting-state.d.ts +3 -0
  78. package/dist/types/sdk/host/model-pin.d.ts +29 -0
  79. package/dist/types/sdk/host/session-runtime.d.ts +2 -1
  80. package/dist/types/sdk/lifecycle-session.d.ts +13 -0
  81. package/dist/types/sdk/router/session-router.d.ts +27 -1
  82. package/dist/types/sdk/session.d.ts +11 -1
  83. package/dist/types/session/agent-session.d.ts +36 -6
  84. package/dist/types/session/artifacts.d.ts +16 -1
  85. package/dist/types/session/internal/managed-session-storage.d.ts +7 -0
  86. package/dist/types/session/session-manager.d.ts +84 -2
  87. package/dist/types/session/session-staging-paths.d.ts +3 -0
  88. package/dist/types/skill-state/canonical-skills.d.ts +1 -1
  89. package/dist/types/skill-state/workflow-hud.d.ts +12 -21
  90. package/dist/types/skill-state/workflow-mutation-guard.d.ts +1 -0
  91. package/dist/types/slash-commands/helpers/autorouting-status.d.ts +8 -0
  92. package/dist/types/system-prompt.d.ts +23 -0
  93. package/dist/types/task/executor.d.ts +35 -4
  94. package/dist/types/task/index.d.ts +17 -3
  95. package/dist/types/task/receipt.d.ts +3 -1
  96. package/dist/types/task/types.d.ts +144 -0
  97. package/dist/types/tools/atomic-file-write.d.ts +72 -0
  98. package/dist/types/tools/descriptors.d.ts +1 -0
  99. package/dist/types/tools/index.d.ts +36 -1
  100. package/dist/types/tools/move-session.d.ts +40 -0
  101. package/dist/types/tools/python.d.ts +12 -0
  102. package/dist/types/tools/resource-gc.d.ts +0 -3
  103. package/dist/types/tools/search.d.ts +8 -1
  104. package/dist/types/tools/skill.d.ts +9 -4
  105. package/dist/types/tools/subagent.d.ts +33 -3
  106. package/dist/types/utils/xml-escape.d.ts +2 -0
  107. package/dist/types/workflow/workflow-intent-diff.d.ts +2 -2
  108. package/dist/types/workflow/workflow-intent-report-consumer.d.ts +1 -1
  109. package/package.json +11 -12
  110. package/scripts/build-sdk-package-smoke.ts +7 -5
  111. package/scripts/check-autorouting-tier-map.ts +183 -0
  112. package/scripts/generate-sdk-operation-inventory.ts +12 -0
  113. package/scripts/generate-tool-catalog.ts +40 -5
  114. package/src/async/job-manager.ts +5 -2
  115. package/src/autoresearch/dashboard.ts +134 -314
  116. package/src/autoresearch/data-context.ts +35 -0
  117. package/src/autoresearch/git.ts +186 -26
  118. package/src/autoresearch/{helpers.ts → harness.ts} +65 -40
  119. package/src/autoresearch/{prompt-setup.md → prompts/prompt-setup.md} +7 -7
  120. package/src/autoresearch/prompts/prompt.md +103 -0
  121. package/src/autoresearch/prompts.ts +126 -0
  122. package/src/autoresearch/runs.ts +708 -0
  123. package/src/autoresearch/types.ts +9 -150
  124. package/src/capability/index.ts +4 -4
  125. package/src/capability/types.ts +20 -0
  126. package/src/cli/accounts-cli.ts +21 -5
  127. package/src/cli/config-cli.ts +25 -1
  128. package/src/cli/fast-help.ts +1 -2
  129. package/src/cli/list-models.ts +5 -8
  130. package/src/cli/setup-cli.ts +8 -0
  131. package/src/cli.ts +2 -21
  132. package/src/commands/autoresearch.ts +33 -0
  133. package/src/commands/coordinator.ts +15 -0
  134. package/src/commands/launch.ts +2 -4
  135. package/src/commands/quick-lane.ts +1 -1
  136. package/src/commands/sdk.ts +24 -0
  137. package/src/commands/state.ts +1 -1
  138. package/src/config/autorouting-contract.ts +474 -0
  139. package/src/config/autorouting-generator.ts +238 -0
  140. package/src/config/autorouting-tier-map.ts +6108 -0
  141. package/src/config/autorouting.ts +120 -0
  142. package/src/config/model-discovery-manager.ts +42 -11
  143. package/src/config/model-registry.ts +348 -68
  144. package/src/config/provider-selection-policy.ts +60 -9
  145. package/src/config/settings-schema.ts +195 -6
  146. package/src/config/settings.ts +62 -1
  147. package/src/config.ts +2 -3
  148. package/src/coordinator/contract.ts +0 -1
  149. package/src/coordinator-mcp/codex-handoff.ts +68 -42
  150. package/src/coordinator-mcp/durability.ts +200 -0
  151. package/src/coordinator-mcp/event-webhook.ts +278 -0
  152. package/src/coordinator-mcp/question-state.ts +109 -46
  153. package/src/coordinator-mcp/server.ts +932 -504
  154. package/src/crash/index-store.ts +126 -22
  155. package/src/crash/record-loader.ts +7 -0
  156. package/src/crash/upstream/relay.ts +82 -34
  157. package/src/cursor.ts +84 -1
  158. package/src/debug/index.ts +26 -0
  159. package/src/defaults/gjc/skills/autoresearch/SKILL.md +116 -0
  160. package/src/defaults/gjc/skills/autoresearch/auto-critic.md +46 -0
  161. package/src/defaults/gjc/skills/autoresearch/auto-iterate.md +42 -0
  162. package/src/defaults/gjc/skills/deep-interview/SKILL.md +17 -37
  163. package/src/defaults/gjc/skills/ralplan/SKILL.md +21 -18
  164. package/src/defaults/gjc/skills/ultragoal/SKILL.md +6 -17
  165. package/src/defaults/gjc/skills/ultragoal/ai-slop-cleaner.md +2 -2
  166. package/src/defaults/gjc-defaults.ts +89 -2
  167. package/src/defaults/gjc-skills.generated.ts +24 -17
  168. package/src/discovery/builtin.ts +18 -5
  169. package/src/discovery/helpers.ts +6 -6
  170. package/src/edit/index.ts +5 -2
  171. package/src/eval/py/executor.ts +8 -0
  172. package/src/extensibility/extensions/loader.ts +135 -13
  173. package/src/extensibility/extensions/wrapper.ts +1 -2
  174. package/src/extensibility/gjc-plugins/schema.ts +1 -1
  175. package/src/extensibility/runtime-skill-discovery.ts +2 -2
  176. package/src/extensibility/skill-management.ts +2 -3
  177. package/src/extensibility/skills.ts +16 -8
  178. package/src/gjc-runtime/autoresearch-runtime.ts +1509 -0
  179. package/src/gjc-runtime/gc-render.ts +0 -1
  180. package/src/gjc-runtime/gc-runtime.ts +1 -11
  181. package/src/gjc-runtime/launch-tmux.ts +3 -3
  182. package/src/gjc-runtime/psmux-detect.ts +2 -2
  183. package/src/gjc-runtime/python-transcript.ts +58 -0
  184. package/src/gjc-runtime/ralplan-runtime.ts +6 -18
  185. package/src/gjc-runtime/session-layout.ts +18 -7
  186. package/src/gjc-runtime/session-state-sidecar.ts +58 -9
  187. package/src/gjc-runtime/state-renderer.ts +0 -38
  188. package/src/gjc-runtime/state-runtime.ts +43 -22
  189. package/src/gjc-runtime/state-schema.ts +1 -1
  190. package/src/gjc-runtime/tmux-common.ts +7 -7
  191. package/src/gjc-runtime/ultragoal-change-set.ts +232 -11
  192. package/src/gjc-runtime/ultragoal-runtime.ts +309 -114
  193. package/src/gjc-runtime/ultragoal-validation-policy.ts +247 -0
  194. package/src/gjc-runtime/workflow-command-ref.ts +15 -11
  195. package/src/gjc-runtime/workflow-manifest.generated.json +413 -532
  196. package/src/gjc-runtime/workflow-manifest.ts +43 -83
  197. package/src/gjc-runtime/workflow-recovery-projection.ts +616 -0
  198. package/src/hooks/skill-keywords.ts +3 -3
  199. package/src/hooks/skill-state.ts +2 -2
  200. package/src/internal-urls/docs-index.generated.ts +26 -24
  201. package/src/lsp/index.ts +25 -5
  202. package/src/main.ts +2 -27
  203. package/src/modes/acp/acp-agent.ts +200 -75
  204. package/src/modes/components/model-selector.ts +216 -13
  205. package/src/modes/components/oauth-selector.ts +5 -5
  206. package/src/modes/components/skill-hud/render.ts +173 -35
  207. package/src/modes/components/smart-routing-panel.ts +531 -0
  208. package/src/modes/components/tool-status-header.ts +6 -3
  209. package/src/modes/controllers/command-controller.ts +17 -7
  210. package/src/modes/controllers/input-controller.ts +44 -9
  211. package/src/modes/controllers/runtime-mcp-command-controller.ts +4 -0
  212. package/src/modes/controllers/selector-controller.ts +298 -65
  213. package/src/modes/interactive-mode.ts +8 -1
  214. package/src/modes/shared/agent-wire/workflow-gate-types.ts +1 -1
  215. package/src/modes/types.ts +9 -1
  216. package/src/prompts/agents/planner.md +1 -1
  217. package/src/prompts/system/system-prompt.md +1 -1
  218. package/src/prompts/system/volatile-project-context.md +2 -1
  219. package/src/prompts/tools/move-session.md +15 -0
  220. package/src/prompts/tools/python.md +12 -0
  221. package/src/prompts/tools/skill-discovery.md +1 -1
  222. package/src/prompts/tools/skill.md +2 -2
  223. package/src/prompts/tools/task-summary.md +1 -0
  224. package/src/prompts/tools/task.md +6 -0
  225. package/src/rlm/artifacts.ts +12 -10
  226. package/src/runtime/optional-runtime-services.ts +3 -2
  227. package/src/runtime/workspace-tree-service.ts +11 -3
  228. package/src/runtime-mcp/config.ts +8 -1
  229. package/src/runtime-mcp/manager.ts +33 -0
  230. package/src/sdk/acp/adapter.ts +48 -1
  231. package/src/sdk/broker/broker.ts +30 -2
  232. package/src/sdk/broker/discovery.ts +217 -2
  233. package/src/sdk/broker/lifecycle.ts +385 -59
  234. package/src/sdk/broker/session-index.ts +243 -48
  235. package/src/sdk/broker/transport.ts +1 -0
  236. package/src/sdk/bus/chat-daemon-control.ts +8 -2
  237. package/src/sdk/bus/chat-daemon-runtime.ts +5 -0
  238. package/src/sdk/bus/index.ts +208 -11
  239. package/src/sdk/bus/kind-aware-reconciliation.ts +16 -0
  240. package/src/sdk/bus/reconciliation-store.ts +27 -5
  241. package/src/sdk/bus/telegram-daemon-contract.ts +12 -2
  242. package/src/sdk/client/client.ts +204 -22
  243. package/src/sdk/host/host.ts +16 -0
  244. package/src/sdk/host/internal-autorouting-state.ts +13 -0
  245. package/src/sdk/host/model-pin.ts +101 -0
  246. package/src/sdk/host/reverse-leases.ts +2 -1
  247. package/src/sdk/host/session-runtime.test.ts +224 -1
  248. package/src/sdk/host/session-runtime.ts +125 -37
  249. package/src/sdk/lifecycle/service.ts +3 -1
  250. package/src/sdk/lifecycle-session.ts +42 -1
  251. package/src/sdk/protocol/operation-inventory.generated.json +66 -0
  252. package/src/sdk/router/session-router.ts +375 -36
  253. package/src/sdk/session.ts +547 -47
  254. package/src/session/account-inventory.ts +1 -1
  255. package/src/session/agent-session.ts +765 -189
  256. package/src/session/artifacts.ts +234 -4
  257. package/src/session/internal/managed-session-storage.ts +91 -4
  258. package/src/session/provider-safety-stop-hint.ts +3 -2
  259. package/src/session/session-manager.ts +775 -16
  260. package/src/session/session-staging-paths.ts +8 -0
  261. package/src/setup/hermes/templates/operator-instructions.v1.md +0 -1
  262. package/src/setup/host-plugin-setup.ts +2 -2
  263. package/src/skill-state/active-state.ts +2 -2
  264. package/src/skill-state/canonical-skills.ts +1 -1
  265. package/src/skill-state/initial-phase.ts +1 -1
  266. package/src/skill-state/workflow-hud.ts +41 -41
  267. package/src/skill-state/workflow-mutation-guard.ts +26 -7
  268. package/src/slash-commands/builtin-registry.ts +77 -0
  269. package/src/slash-commands/helpers/autorouting-status.ts +55 -0
  270. package/src/system-prompt.ts +102 -5
  271. package/src/task/discovery.ts +2 -3
  272. package/src/task/executor.ts +617 -43
  273. package/src/task/index.ts +262 -15
  274. package/src/task/receipt.ts +30 -0
  275. package/src/task/render.ts +11 -1
  276. package/src/task/types.ts +316 -0
  277. package/src/tools/atomic-file-write.ts +594 -0
  278. package/src/tools/bisect.ts +3 -2
  279. package/src/tools/descriptors.test.ts +3 -0
  280. package/src/tools/descriptors.ts +24 -0
  281. package/src/tools/image-gen.ts +5 -1
  282. package/src/tools/index.ts +62 -5
  283. package/src/tools/move-session.ts +107 -0
  284. package/src/tools/python.ts +205 -0
  285. package/src/tools/read.ts +88 -15
  286. package/src/tools/renderers.ts +2 -0
  287. package/src/tools/resource-gc.ts +8 -177
  288. package/src/tools/search.ts +30 -6
  289. package/src/tools/skill-discovery.ts +1 -0
  290. package/src/tools/skill.ts +37 -7
  291. package/src/tools/subagent-render.ts +119 -13
  292. package/src/tools/subagent.ts +91 -74
  293. package/src/tools/tool-catalog.generated.ts +64 -2
  294. package/src/tools/write.ts +16 -12
  295. package/src/utils/xml-escape.ts +14 -0
  296. package/src/web/search/providers/codex.ts +2 -2
  297. package/src/workflow/workflow-intent-diff.ts +13 -12
  298. package/src/workflow/workflow-intent-report-consumer.ts +6 -6
  299. package/dist/types/autoresearch/helpers.d.ts +0 -24
  300. package/dist/types/autoresearch/index.d.ts +0 -2
  301. package/dist/types/autoresearch/state.d.ts +0 -17
  302. package/dist/types/autoresearch/storage.d.ts +0 -142
  303. package/dist/types/autoresearch/tools/init-experiment.d.ts +0 -31
  304. package/dist/types/autoresearch/tools/log-experiment.d.ts +0 -23
  305. package/dist/types/autoresearch/tools/run-experiment.d.ts +0 -8
  306. package/dist/types/autoresearch/tools/update-notes.d.ts +0 -12
  307. package/dist/types/commands/team.d.ts +0 -24
  308. package/dist/types/gjc-runtime/team-gc.d.ts +0 -7
  309. package/dist/types/gjc-runtime/team-launch.d.ts +0 -61
  310. package/dist/types/gjc-runtime/team-notify.d.ts +0 -45
  311. package/dist/types/gjc-runtime/team-runtime.d.ts +0 -336
  312. package/dist/types/gjc-runtime/team-store.d.ts +0 -168
  313. package/dist/types/gjc-runtime/team-worker-heartbeat.d.ts +0 -43
  314. package/dist/types/gjc-runtime/team-worker-memory-guard.d.ts +0 -92
  315. package/dist/types/gjc-runtime/team-workers.d.ts +0 -83
  316. package/dist/types/rlm/index.d.ts +0 -47
  317. package/dist/types/rlm/preset.d.ts +0 -23
  318. package/dist/types/rlm/python-tool.d.ts +0 -16
  319. package/src/autoresearch/command-resume.md +0 -14
  320. package/src/autoresearch/index.ts +0 -536
  321. package/src/autoresearch/prompt.md +0 -103
  322. package/src/autoresearch/resume-message.md +0 -10
  323. package/src/autoresearch/state.ts +0 -273
  324. package/src/autoresearch/storage.ts +0 -699
  325. package/src/autoresearch/tools/init-experiment.ts +0 -272
  326. package/src/autoresearch/tools/log-experiment.ts +0 -524
  327. package/src/autoresearch/tools/run-experiment.ts +0 -475
  328. package/src/autoresearch/tools/update-notes.ts +0 -109
  329. package/src/commands/rlm.ts +0 -19
  330. package/src/commands/team.ts +0 -238
  331. package/src/defaults/gjc/skills/deep-interview/auto-research-greenfield.md +0 -42
  332. package/src/defaults/gjc/skills/team/SKILL.md +0 -467
  333. package/src/gjc-runtime/team-gc.ts +0 -49
  334. package/src/gjc-runtime/team-launch.ts +0 -404
  335. package/src/gjc-runtime/team-notify.ts +0 -326
  336. package/src/gjc-runtime/team-runtime.ts +0 -5923
  337. package/src/gjc-runtime/team-store.ts +0 -878
  338. package/src/gjc-runtime/team-worker-heartbeat.ts +0 -149
  339. package/src/gjc-runtime/team-worker-memory-guard.ts +0 -363
  340. package/src/gjc-runtime/team-workers.ts +0 -1123
  341. package/src/prompts/system/rlm-report-command.md +0 -1
  342. package/src/prompts/system/rlm-research.md +0 -23
  343. package/src/rlm/index.ts +0 -365
  344. package/src/rlm/preset.ts +0 -76
  345. package/src/rlm/python-tool.ts +0 -68
@@ -5,6 +5,7 @@
5
5
  <agent id="{{id}}" agent="{{agent}}">
6
6
  <status>{{status}}</status>
7
7
  {{#if meta}}<meta lines="{{meta.lineCount}}" size="{{meta.charSize}}" />{{/if}}
8
+ {{#if routing}}<routing tier="{{routing.tier}}" model="{{routing.effectiveModel}}" note="{{routing.note}}" />{{/if}}
8
9
  <synopsis{{#if outputUri}} ref="{{outputUri}}"{{/if}}>
9
10
  {{synopsis}}
10
11
  </synopsis>
@@ -82,3 +82,9 @@ Agent spawning is disabled for this context.
82
82
  {{/list}}
83
83
  {{/if}}
84
84
  </agents>
85
+
86
+ {{#if autoroutingActive}}
87
+ <autorouting-guidance>
88
+ Choose a tier by agent role/type, per-call complexity, and cost intent: fast for mechanical/lookup/high-volume work where cheap tokens are the point; balanced (default) for ordinary implementation/review lanes; strong for deep design, hard debugging, or high-stakes review where the cost is justified. Provider availability/auth is enforced by deterministic code and is never an input to tier choice. Omitting tier is fine and routes as balanced.
89
+ </autorouting-guidance>
90
+ {{/if}}
@@ -1,17 +1,19 @@
1
1
  /**
2
- * RLM session artifact layout under <cwd>/.gjc/_session-{gjcSessionId}/rlm/<rlmSessionId>/.
2
+ * RLM session artifact layout under
3
+ * <cwd>/.gjc/_session-{gjcSessionId}/autoresearch/runs/<rlmSessionId>/.
3
4
  *
4
5
  * The GJC session id (process boundary) scopes the directory; the RLM session id
5
6
  * names the individual research run within it. The two ids are kept distinct.
7
+ * The whole subtree lives under the session autoresearch root.
6
8
  */
7
9
  import * as fs from "node:fs/promises";
8
10
  import * as path from "node:path";
9
11
  import { readNotebookDocument } from "../edit/notebook";
10
- import { rlmArtifactRoot } from "../gjc-runtime/session-layout";
12
+ import { autoresearchRlmArtifactRoot } from "../gjc-runtime/session-layout";
11
13
  import { resolveGjcSessionForWrite } from "../gjc-runtime/session-resolution";
12
14
  import type { RlmArtifactPaths } from "./types";
13
15
 
14
- export const RLM_DIR_SEGMENT = "rlm";
16
+ export const RLM_DIR_SEGMENT = "runs";
15
17
 
16
18
  const SESSION_ID_RE = /^[A-Za-z0-9_-]+$/;
17
19
 
@@ -26,13 +28,13 @@ export function generateRlmSessionId(now: Date = new Date()): string {
26
28
  return `${stamp}-${suffix}`;
27
29
  }
28
30
 
29
- export function resolveRlmArtifactPaths(cwd: string, sessionId: string): RlmArtifactPaths {
31
+ export function resolveRlmArtifactPaths(cwd: string, sessionId: string, gjcSessionId?: string): RlmArtifactPaths {
30
32
  if (!isValidRlmSessionId(sessionId)) {
31
33
  throw new Error(`Invalid RLM session id: ${JSON.stringify(sessionId)}`);
32
34
  }
33
- const dir = rlmArtifactRoot(
35
+ const dir = autoresearchRlmArtifactRoot(
34
36
  cwd,
35
- resolveGjcSessionForWrite(cwd, { envSessionId: process.env.GJC_SESSION_ID }).gjcSessionId,
37
+ gjcSessionId ?? resolveGjcSessionForWrite(cwd, { envSessionId: process.env.GJC_SESSION_ID }).gjcSessionId,
36
38
  sessionId,
37
39
  );
38
40
  return {
@@ -48,8 +50,8 @@ export async function ensureRlmSessionDir(paths: RlmArtifactPaths): Promise<void
48
50
  await fs.mkdir(paths.dir, { recursive: true });
49
51
  }
50
52
 
51
- export async function rlmSessionExists(cwd: string, sessionId: string): Promise<boolean> {
52
- const paths = resolveRlmArtifactPaths(cwd, sessionId);
53
+ export async function rlmSessionExists(cwd: string, sessionId: string, gjcSessionId?: string): Promise<boolean> {
54
+ const paths = resolveRlmArtifactPaths(cwd, sessionId, gjcSessionId);
53
55
  try {
54
56
  const stat = await fs.stat(paths.dir);
55
57
  return stat.isDirectory();
@@ -58,8 +60,8 @@ export async function rlmSessionExists(cwd: string, sessionId: string): Promise<
58
60
  }
59
61
  }
60
62
 
61
- export async function readRlmNotebookIfPresent(cwd: string, sessionId: string) {
62
- const paths = resolveRlmArtifactPaths(cwd, sessionId);
63
+ export async function readRlmNotebookIfPresent(cwd: string, sessionId: string, gjcSessionId?: string) {
64
+ const paths = resolveRlmArtifactPaths(cwd, sessionId, gjcSessionId);
63
65
  try {
64
66
  return await readNotebookDocument(paths.notebookPath, paths.notebookPath);
65
67
  } catch (error) {
@@ -20,7 +20,8 @@ export type OptionalRuntimeServicesOverrides = Partial<OptionalRuntimeServices>;
20
20
 
21
21
  /** Context needed by services whose identity is scoped to the session cwd. */
22
22
  export interface OptionalRuntimeServicesContext {
23
- cwd?: string;
23
+ /** Session cwd. Pass a getter when the session can rescope (`move_session`). */
24
+ cwd?: string | (() => string);
24
25
  }
25
26
 
26
27
  /**
@@ -32,7 +33,7 @@ export function createOptionalRuntimeServices(
32
33
  overrides: OptionalRuntimeServicesOverrides = {},
33
34
  context: OptionalRuntimeServicesContext = {},
34
35
  ): OptionalRuntimeServices {
35
- const cwd = context.cwd ?? process.cwd();
36
+ const cwd = context.cwd ?? (() => process.cwd());
36
37
  return {
37
38
  memoryBackend: overrides.memoryBackend ?? createMemoryBackendService(settings),
38
39
  workspaceTree: overrides.workspaceTree ?? createWorkspaceTreeService(settings, cwd),
@@ -15,8 +15,16 @@ export interface WorkspaceTreeRuntime {
15
15
  * Build the workspace-tree service without importing the native scanner until
16
16
  * the service is activated. The scan itself remains the single authority for
17
17
  * both eager startup and the lazy first-turn barrier.
18
+ *
19
+ * `cwd` is resolved per scan rather than captured once: a session that rescopes
20
+ * (`move_session`, `/move`) must have its refreshes re-root at the new cwd,
21
+ * otherwise every later tree describes the abandoned launcher root.
18
22
  */
19
- export function createWorkspaceTreeService(settings: Settings, cwd: string): LazyService<WorkspaceTreeRuntime> {
23
+ export function createWorkspaceTreeService(
24
+ settings: Settings,
25
+ cwd: string | (() => string),
26
+ ): LazyService<WorkspaceTreeRuntime> {
27
+ const resolveCwd = typeof cwd === "function" ? cwd : () => cwd;
20
28
  return createLazyService({
21
29
  id: "workspaceTree",
22
30
  enabled: () => settings.get("workspaceTree.mode") === "eager" || settings.get("workspaceTree.mode") === "lazy",
@@ -24,7 +32,7 @@ export function createWorkspaceTreeService(settings: Settings, cwd: string): Laz
24
32
  const scan = async (): Promise<WorkspaceTree> => {
25
33
  if (signal.aborted) throw new Error("Workspace-tree scan was aborted before it started.");
26
34
  const { buildWorkspaceTree } = await import("../workspace-tree");
27
- const tree = await buildWorkspaceTree(cwd, { timeoutMs: WORKSPACE_TREE_SCAN_TIMEOUT_MS });
35
+ const tree = await buildWorkspaceTree(resolveCwd(), { timeoutMs: WORKSPACE_TREE_SCAN_TIMEOUT_MS });
28
36
  if (signal.aborted) throw new Error("Workspace-tree scan was aborted before it completed.");
29
37
  return tree;
30
38
  };
@@ -34,7 +42,7 @@ export function createWorkspaceTreeService(settings: Settings, cwd: string): Laz
34
42
  snapshot,
35
43
  refresh: async () => {
36
44
  const { buildWorkspaceTree } = await import("../workspace-tree");
37
- return buildWorkspaceTree(cwd, { timeoutMs: WORKSPACE_TREE_SCAN_TIMEOUT_MS });
45
+ return buildWorkspaceTree(resolveCwd(), { timeoutMs: WORKSPACE_TREE_SCAN_TIMEOUT_MS });
38
46
  },
39
47
  },
40
48
  };
@@ -34,6 +34,12 @@ export interface LoadMCPConfigsOptions {
34
34
  nativeOnly?: boolean;
35
35
  /** Load only this explicit MCP config file. */
36
36
  configPath?: string;
37
+ /**
38
+ * Agent directory that holds the user scope (`<agentDir>/mcp.json`).
39
+ * Default: `getAgentDir()`. Sessions created with their own `agentDir` pass it
40
+ * so discovery and `gjc mcp add --scope user` read and write the same file.
41
+ */
42
+ agentDir?: string;
37
43
  }
38
44
 
39
45
  /** Result of loading MCP configs */
@@ -140,6 +146,7 @@ export async function loadAllMCPConfigs(cwd: string, options?: LoadMCPConfigsOpt
140
146
  // implicit runtime authorities.
141
147
  const result = await loadCapability<MCPServer>(mcpCapability.id, {
142
148
  cwd,
149
+ agentDir: options?.agentDir,
143
150
  providers: options?.nativeOnly === true ? ["native"] : undefined,
144
151
  });
145
152
  // Filter out project-level configs if disabled
@@ -150,7 +157,7 @@ export async function loadAllMCPConfigs(cwd: string, options?: LoadMCPConfigsOpt
150
157
  // must not abort discovery of valid servers in the other scope (the
151
158
  // capability loader itself is already per-file tolerant).
152
159
  const [userDisabled, projectDisabled] = await Promise.all([
153
- readDisabledServers(getMCPConfigPath("user", cwd)).catch(() => []),
160
+ readDisabledServers(getMCPConfigPath("user", cwd, options?.agentDir)).catch(() => []),
154
161
  readDisabledServers(getMCPConfigPath("project", cwd)).catch(() => []),
155
162
  ]);
156
163
  disabledServers = new Set([...userDisabled, ...projectDisabled]);
@@ -134,6 +134,21 @@ export function resolveExactConfigStartupTimeoutMs(configs: MCPServerConfig[]):
134
134
  return Math.max(...effectiveTimeouts) + STARTUP_TIMEOUT_GRACE_MS;
135
135
  }
136
136
 
137
+ /**
138
+ * Whether `config` declared a connection window that is still open `elapsedMs`
139
+ * into startup.
140
+ *
141
+ * The startup wait bounds how long session start blocks; a declared `timeout`
142
+ * bounds how long the server itself may take to come up (`connectToServer`
143
+ * enforces it). Those are different budgets: the wait elapsing says nothing
144
+ * about whether the operator's declared window has been spent.
145
+ */
146
+ export function withinDeclaredConnectionWindow(config: MCPServerConfig, elapsedMs: number): boolean {
147
+ const timeout = config.timeout;
148
+ if (typeof timeout !== "number" || !Number.isFinite(timeout) || timeout <= 0) return false;
149
+ return elapsedMs < timeout;
150
+ }
151
+
137
152
  function trackPromise<T>(promise: Promise<T>): TrackedPromise<T> {
138
153
  const tracked: TrackedPromise<T> = { promise, status: "pending" };
139
154
  promise.then(
@@ -1155,6 +1170,7 @@ export class MCPManager {
1155
1170
  });
1156
1171
  }
1157
1172
  }
1173
+ const startupStartedAt = Date.now();
1158
1174
  const startupOutcome = await Promise.race([
1159
1175
  Promise.allSettled(connectionTasks.map(task => task.tracked.promise)).then(() => undefined),
1160
1176
  delay(startupTimeoutMs).then(() => undefined),
@@ -1190,7 +1206,24 @@ export class MCPManager {
1190
1206
 
1191
1207
  const pendingWithoutCache = pendingTasks.filter(task => !cachedTools.has(task.name));
1192
1208
  if (pendingWithoutCache.length > 0) {
1209
+ // The startup wait elapsing means "stop blocking session start", not
1210
+ // "this server failed". A server whose operator declared a `timeout`
1211
+ // (`gjc mcp add --timeout`) asked to wait that long for it, so while it
1212
+ // is still inside that window it keeps connecting in the background
1213
+ // under `connectToServer`'s own timeout, and the background tool load
1214
+ // adopts it. Only a server that declared no window, or already spent it,
1215
+ // is torn down and reported. Exact-config (`toolsOnly`) startup still
1216
+ // fails fast: it builds a catalog once, so a missing server is an error.
1217
+ const startupElapsedMs = Date.now() - startupStartedAt;
1193
1218
  for (const task of pendingWithoutCache) {
1219
+ if (!this.#toolsOnly && withinDeclaredConnectionWindow(task.config, startupElapsedMs)) {
1220
+ logger.warn("MCP server still connecting after the startup wait", {
1221
+ path: `mcp:${task.name}`,
1222
+ startupWaitMs: startupTimeoutMs,
1223
+ declaredTimeoutMs: task.config.timeout,
1224
+ });
1225
+ continue;
1226
+ }
1194
1227
  const message = `MCP server connection timed out during startup: ${task.name}`;
1195
1228
  errors.set(task.name, this.#serverError(message));
1196
1229
  reportedErrors.add(task.name);
@@ -73,6 +73,8 @@ const ACP_MCP_PRESERVED_LAUNCH_CODES = new Set([
73
73
  "model_profile_registry_error",
74
74
  "startup_admission_refused",
75
75
  "startup_admission_timeout",
76
+ "ready_then_exited",
77
+ "endpoint_unreadable",
76
78
  ]);
77
79
 
78
80
  /**
@@ -127,6 +129,21 @@ function isLifecycleOperation(operation: string): boolean {
127
129
  * Pure ACP-to-SDK adapter. It deliberately owns neither an AgentSession nor an
128
130
  * ACP bridge: all session work is performed through authenticated v3 frames.
129
131
  */
132
+ /**
133
+ * Provider leases are renewed only through the maintenance capability (#4689).
134
+ * An attachment without it must be rejected at the admission boundary with an
135
+ * explicit migration error: falling back to `send()` would restore the 5s
136
+ * heartbeat-forced locked index rescan, and accepting it silently would leave
137
+ * live leases quietly un-renewed until they expire.
138
+ */
139
+ function assertMaintenanceCapability(attachment: SessionAttachment): void {
140
+ if (typeof attachment.sendMaintenance === "function") return;
141
+ throw new AcpSdkAdapterError(
142
+ "operation_prohibited",
143
+ "SDK session attachment does not implement sendMaintenance(leaseId); provider leases cannot be renewed. Update the attachment implementation to the current SessionAttachment capability.",
144
+ );
145
+ }
146
+
130
147
  export class AcpSdkAdapter {
131
148
  readonly #client?: SdkClient;
132
149
  readonly #router?: SessionRouter;
@@ -184,6 +201,11 @@ export class AcpSdkAdapter {
184
201
  acceptAttachment(attachment: SessionAttachment): void {
185
202
  if (!this.#router || attachment.sessionId !== this.#sessionId)
186
203
  throw new AcpSdkAdapterError("invalid_input", "ACP attachment does not match this session adapter.");
204
+ // Reject an attachment that cannot renew provider leases at the handoff
205
+ // boundary (#4730 review), not just at start(): acceptAttachment is the
206
+ // single admission point for the replacement/ready paths too, so a
207
+ // capability-less replacement can never silently take over live leases.
208
+ assertMaintenanceCapability(attachment);
187
209
  this.#abortActiveReverseRequests();
188
210
  this.#attachment = attachment;
189
211
  this.#connectionId = undefined;
@@ -202,6 +224,11 @@ export class AcpSdkAdapter {
202
224
  }
203
225
 
204
226
  async attachmentReady(attachment: SessionAttachment): Promise<void> {
227
+ // Guard BEFORE the branch (#4730 review): the same-object path below never
228
+ // reaches acceptAttachment, so guarding only inside that one arm let a
229
+ // current attachment without the maintenance capability activate providers
230
+ // and acquire leases it can never renew.
231
+ assertMaintenanceCapability(attachment);
205
232
  if (this.#attachment !== attachment) this.acceptAttachment(attachment);
206
233
  else {
207
234
  this.#abortActiveReverseRequests();
@@ -251,6 +278,7 @@ export class AcpSdkAdapter {
251
278
  }
252
279
  if (this.#router) {
253
280
  if (!this.#attachment?.isCurrent()) return;
281
+ if (this.#attachment) assertMaintenanceCapability(this.#attachment);
254
282
  await this.#activateProviders();
255
283
  } else {
256
284
  await this.#activateProviders();
@@ -576,13 +604,32 @@ export class AcpSdkAdapter {
576
604
  if (!attachment?.isCurrent()) throw new SessionRouterError("pre_send", "SDK session attachment is stale.");
577
605
  await Promise.resolve(attachment.send(frame));
578
606
  }
607
+ /** Lease heartbeats are idempotent maintenance: they skip the authority reconcile (#4689). */
608
+ async #sendLeaseHeartbeat(leaseId: string): Promise<void> {
609
+ if (!this.#router)
610
+ throw new AcpSdkAdapterError(
611
+ "operation_prohibited",
612
+ "Live session sends require the current Router attachment.",
613
+ );
614
+ const attachment = this.#attachment;
615
+ if (!attachment?.isCurrent()) throw new SessionRouterError("pre_send", "SDK session attachment is stale.");
616
+ // Fail closed when the capability is absent (#4730 review). Falling back to
617
+ // send() would put the 5s heartbeat back on the locked authority reconcile,
618
+ // which is the exact idle cost this fix removes.
619
+ if (typeof attachment.sendMaintenance !== "function")
620
+ throw new SessionRouterError(
621
+ "pre_send",
622
+ "SDK session attachment does not support provider-lease maintenance heartbeats.",
623
+ );
624
+ await Promise.resolve(attachment.sendMaintenance(leaseId));
625
+ }
579
626
 
580
627
  async #heartbeatLeases(): Promise<void> {
581
628
  if (this.#closed) return;
582
629
  try {
583
630
  if (!this.#router) return;
584
631
  if (!this.#attachment?.isCurrent()) return;
585
- for (const leaseId of this.#leases.values()) await this.#sendSession({ type: "provider_heartbeat", leaseId });
632
+ for (const leaseId of this.#leases.values()) await this.#sendLeaseHeartbeat(leaseId);
586
633
  } catch (error) {
587
634
  if (error instanceof SessionRouterError && error.phase === "pre_send") return;
588
635
  this.#reportReconnectFailure(error);
@@ -6,6 +6,8 @@ import path from "node:path";
6
6
  import type { NativeDirectoryTreeSnapshot } from "@gajae-code/natives";
7
7
  import { logger } from "@gajae-code/utils";
8
8
  import type { ModelProfileErrorDetails } from "../../config/model-profile-contract";
9
+ import { planLaunchWorktree } from "../../gjc-runtime/launch-worktree";
10
+ import { createDefaultSdkHostModelResolver, type SdkHostModelResolver } from "../host/model-pin";
9
11
  import {
10
12
  type DirectoryMigrationPolicy,
11
13
  listManagedSessionCandidates,
@@ -27,9 +29,7 @@ import {
27
29
  redactBrokerDiscovery,
28
30
  } from "./discovery";
29
31
  import { deriveIdempotencyIdentity } from "./identity";
30
-
31
32
  import { canonicalDeleteLocatorPath, executeLifecycle, isCanonicalSessionId } from "./lifecycle";
32
-
33
33
  import {
34
34
  type LifecycleDurableEffectsReceipt,
35
35
  LifecycleLedger,
@@ -56,6 +56,22 @@ type ResolvedBrokerSettings = {
56
56
  resolveDirectoryMigration: (_cwd: string) => Promise<DirectoryMigrationPolicy>;
57
57
  };
58
58
 
59
+ function modelResolutionCwd(input: Record<string, unknown>): string | undefined {
60
+ const cwd = typeof input.cwd === "string" ? input.cwd : undefined;
61
+ if (!cwd) return undefined;
62
+ const target = input.target;
63
+ if (!target || typeof target !== "object" || Array.isArray(target)) return cwd;
64
+ const worktree = (target as Record<string, unknown>).worktree;
65
+ if (!worktree || typeof worktree !== "object" || Array.isArray(worktree)) return cwd;
66
+ if ((worktree as Record<string, unknown>).enabled !== true) return cwd;
67
+ const name = (worktree as Record<string, unknown>).name;
68
+ const planned =
69
+ typeof name === "string" && name.length > 0
70
+ ? planLaunchWorktree(cwd, { enabled: true, detached: false, name })
71
+ : planLaunchWorktree(cwd, { enabled: true, detached: true, name: null });
72
+ return planned.enabled ? path.resolve(planned.worktreePath) : cwd;
73
+ }
74
+
59
75
  export type BrokerErrorCode =
60
76
  | "idempotency_conflict"
61
77
  | "terminal_uncertain"
@@ -65,6 +81,8 @@ export type BrokerErrorCode =
65
81
  | "resource_gone"
66
82
  | "invalid_input"
67
83
  | "spawn_failed"
84
+ | "ready_then_exited"
85
+ | "endpoint_unreadable"
68
86
  | "startup_admission_timeout"
69
87
  | "startup_admission_refused"
70
88
  | "readiness_timeout"
@@ -796,6 +814,7 @@ export class Broker {
796
814
  #completion!: Promise<void>;
797
815
  #resolveCompletion!: () => void;
798
816
  #rejectCompletion!: (error: unknown) => void;
817
+ readonly #resolveModelPin: SdkHostModelResolver;
799
818
  constructor(settings: BrokerSettings) {
800
819
  this.settings = {
801
820
  agentDir: settings.agentDir,
@@ -806,6 +825,7 @@ export class Broker {
806
825
  };
807
826
  this.index = new SessionIndex(settings.agentDir);
808
827
  this.ledger = new LifecycleLedger(settings.agentDir);
828
+ this.#resolveModelPin = createDefaultSdkHostModelResolver(this.settings.agentDir);
809
829
  this.#lock = path.join(settings.agentDir, "sdk", "broker.lock");
810
830
  const completion = Promise.withResolvers<void>();
811
831
  this.#completion = completion.promise;
@@ -1496,6 +1516,14 @@ export class Broker {
1496
1516
  return page;
1497
1517
  }
1498
1518
  if (operation === "session.get_endpoint") return this.#endpoint(input);
1519
+ if (operation === "model.resolve") {
1520
+ try {
1521
+ const cwd = modelResolutionCwd(input);
1522
+ return { ok: true, result: await this.#resolveModelPin(input.model, { cwd }) };
1523
+ } catch {
1524
+ return error("unavailable", "SDK host model resolution is unavailable.");
1525
+ }
1526
+ }
1499
1527
  if (operation === "broker.lookup_lifecycle") {
1500
1528
  const requestedOperation = typeof input.operation === "string" ? input.operation : undefined;
1501
1529
  const requestedFingerprint = typeof input.fingerprint === "string" ? input.fingerprint : undefined;
@@ -1,4 +1,5 @@
1
1
  import { randomBytes } from "node:crypto";
2
+ import * as fsSync from "node:fs";
2
3
  import * as fs from "node:fs/promises";
3
4
  import path from "node:path";
4
5
 
@@ -24,12 +25,221 @@ export interface RetainedBrokerDiscovery {
24
25
  close(): void;
25
26
  }
26
27
 
28
+ /**
29
+ * Objects retained broker publication authority opens, in the order and with
30
+ * the access mode the native layer opens them. Every one is opened no-follow,
31
+ * so a symlink or a missing entry withholds authority; the lock record is
32
+ * opened read-only and only the published record read/write, so a
33
+ * readable-but-unwritable lock record is not an obstruction.
34
+ *
35
+ * A wrong file kind is only ever reported through the open the native itself
36
+ * would have failed: `O_DIRECTORY` refuses a non-directory, and the read/write
37
+ * open of the published record refuses a directory with `EISDIR`. The kind is
38
+ * never re-judged after a successful open, because the native layer does not --
39
+ * it accepts, for example, a directory or a character device as the lock
40
+ * record, and refuses a character-device published record only later, for its
41
+ * missing heartbeat. Judging kind here would blame an object the native never
42
+ * rejected and hide the real obstruction.
43
+ */
44
+ const BROKER_PUBLICATION_OBJECTS = [
45
+ { name: "sdk", segments: ["sdk"], directory: true, write: false },
46
+ { name: "sdk/broker.lock", segments: ["sdk", "broker.lock"], directory: true, write: false },
47
+ {
48
+ name: "sdk/broker.lock/owner.json",
49
+ segments: ["sdk", "broker.lock", "owner.json"],
50
+ directory: false,
51
+ write: false,
52
+ },
53
+ { name: "sdk/broker.json", segments: ["sdk", "broker.json"], directory: false, write: true },
54
+ ] as const;
55
+
56
+ /** Width the native layer requires of the published `heartbeatAt` field. */
57
+ const FIXED_WIDTH_HEARTBEAT_DIGITS = 13;
58
+
59
+ /** Bound on the published record the diagnostic will read through its descriptor. */
60
+ const MAX_DIAGNOSTIC_PUBLISHED_BYTES = 1 << 20;
61
+
62
+ /**
63
+ * Bound on the agent directory in the failure message. Broker startup persists
64
+ * a 512-character reason, and an unbounded directory would truncate away the
65
+ * obstruction this diagnostic exists to report.
66
+ */
67
+ const MAX_AGENT_DIR_IN_MESSAGE = 120;
68
+
69
+ function boundedAgentDir(agentDir: string): string {
70
+ return agentDir.length <= MAX_AGENT_DIR_IN_MESSAGE
71
+ ? agentDir
72
+ : `...${agentDir.slice(agentDir.length - (MAX_AGENT_DIR_IN_MESSAGE - 3))}`;
73
+ }
74
+
75
+ function sanitizedAgentDir(agentDir: string): string {
76
+ const sanitized = agentDir.replace(/[\p{Cc}\p{Cf}\u2028\u2029]/gu, character => {
77
+ const codePoint = character.codePointAt(0) ?? 0;
78
+ return `\\u${codePoint.toString(16).padStart(4, "0")}`;
79
+ });
80
+ return boundedAgentDir(sanitized);
81
+ }
82
+
83
+ const NATIVE_RETAINED_FAILURE = /\[retained-publication object=([^;\]]+); reason=([^\]]+)\]$/;
84
+
85
+ function nativeRetainedObstruction(error: unknown): string | undefined {
86
+ if (!(error instanceof Error)) return undefined;
87
+ const match = NATIVE_RETAINED_FAILURE.exec(error.message);
88
+ if (!match) return undefined;
89
+ const [, object, reason] = match;
90
+ if (reason === "heartbeat-missing") return `${object} has no heartbeatAt field`;
91
+ if (reason === "heartbeat-width")
92
+ return `${object} heartbeatAt is not a fixed-width ${FIXED_WIDTH_HEARTBEAT_DIGITS}-digit timestamp`;
93
+ if (reason === "non-regular") return `${object} is not a regular file`;
94
+ if (reason === "errno-ENOENT") return `${object} is missing`;
95
+ if (reason === "errno-EISDIR") return `${object} is not a regular file`;
96
+ if (reason === "unsupported-platform") return "retained publication authority is not implemented on this platform";
97
+ // The native reason names the stage that refused, so the prose must not
98
+ // claim an open failed when the descriptor was already open: `errno-`/`io-`
99
+ // come from the open itself, `read-` from reading the published record, and
100
+ // `clone-`/`metadata` from inspecting an object already opened.
101
+ if (reason.startsWith("errno-")) return `${object} could not be opened (${reason.slice("errno-".length)})`;
102
+ if (reason.startsWith("io-")) return `${object} could not be opened (${reason.slice("io-".length)})`;
103
+ if (reason.startsWith("read-")) return `${object} could not be read (${reason.slice("read-".length)})`;
104
+ if (reason.startsWith("clone-")) return `${object} could not be inspected (${reason.slice("clone-".length)})`;
105
+ if (reason === "metadata") return `${object} could not be inspected`;
106
+ if (reason.startsWith("metadata-")) return `${object} could not be inspected (${reason.slice("metadata-".length)})`;
107
+ return `${object} withheld publication authority (${reason})`;
108
+ }
109
+
110
+ /**
111
+ * Name the first condition the published record itself violates, reading only
112
+ * through the descriptor the no-follow open already verified. Reopening by
113
+ * pathname would let a replacement in that window redirect the diagnostic to a
114
+ * different object, or block on a name that is no longer a regular file.
115
+ */
116
+ function describeWithheldPublishedRecord(descriptor: number, size: number): string | undefined {
117
+ if (size > MAX_DIAGNOSTIC_PUBLISHED_BYTES) return undefined;
118
+ const bytes = Buffer.alloc(Math.min(MAX_DIAGNOSTIC_PUBLISHED_BYTES, Math.max(size, 4096)));
119
+ let read = 0;
120
+ try {
121
+ while (read < bytes.length) {
122
+ const count = fsSync.readSync(descriptor, bytes, read, bytes.length - read, read);
123
+ if (count === 0) break;
124
+ read += count;
125
+ }
126
+ } catch (error) {
127
+ return `sdk/broker.json could not be read (${(error as NodeJS.ErrnoException).code ?? "unknown error"})`;
128
+ }
129
+ let hasMore = false;
130
+ if (read === bytes.length) {
131
+ try {
132
+ hasMore = fsSync.readSync(descriptor, Buffer.alloc(1), 0, 1, read) > 0;
133
+ } catch {
134
+ return undefined;
135
+ }
136
+ }
137
+ // latin1 keeps one character per byte, so offsets match the native's byte scan.
138
+ const published = bytes.subarray(0, read).toString("latin1");
139
+ const needle = '"heartbeatAt":';
140
+ const at = published.indexOf(needle);
141
+ // A record larger than this bounded read is inconclusive, never an absence.
142
+ if (at < 0) return hasMore ? undefined : "sdk/broker.json has no heartbeatAt field";
143
+ const digits = published.slice(at + needle.length, at + needle.length + FIXED_WIDTH_HEARTBEAT_DIGITS);
144
+ const next = published.charAt(at + needle.length + FIXED_WIDTH_HEARTBEAT_DIGITS);
145
+ if (!/^[0-9]{13}$/.test(digits) || /^[0-9]$/.test(next))
146
+ return `sdk/broker.json heartbeatAt is not a fixed-width ${FIXED_WIDTH_HEARTBEAT_DIGITS}-digit timestamp`;
147
+ return undefined;
148
+ }
149
+
150
+ function isSymbolicLink(pathname: string): boolean {
151
+ try {
152
+ return fsSync.lstatSync(pathname).isSymbolicLink();
153
+ } catch {
154
+ return false;
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Name the first condition that withholds retained publication authority.
160
+ *
161
+ * The native layer reports withheld authority as one opaque failure, so a
162
+ * broker that cannot retain its own publication dies without naming the object
163
+ * responsible. Operators then cannot distinguish a missing lock record from a
164
+ * redirected `sdk` directory (a symlinked agent-directory entry is refused by
165
+ * the no-follow open, which is how shared multi-account layouts fail), and the
166
+ * only way to learn the precondition is to read the native source.
167
+ *
168
+ * This inspects the same objects in the same order and returns a description of
169
+ * the first obstruction, or `undefined` when every precondition holds and the
170
+ * refusal came from a race the caller must still fail closed on.
171
+ */
172
+ function describeWithheldPublicationAuthority(agentDir: string): string | undefined {
173
+ for (const object of BROKER_PUBLICATION_OBJECTS) {
174
+ const pathname = path.join(agentDir, ...object.segments);
175
+ // O_NONBLOCK keeps the diagnostic itself from parking on an object that is
176
+ // no longer a regular file; the following fstat still names the wrong kind.
177
+ const flags = object.directory
178
+ ? fsSync.constants.O_RDONLY | fsSync.constants.O_DIRECTORY | fsSync.constants.O_NOFOLLOW
179
+ : (object.write ? fsSync.constants.O_RDWR : fsSync.constants.O_RDONLY) |
180
+ fsSync.constants.O_NOFOLLOW |
181
+ fsSync.constants.O_NONBLOCK;
182
+ let descriptor: number;
183
+ try {
184
+ descriptor = fsSync.openSync(pathname, flags);
185
+ } catch (error) {
186
+ const code = (error as NodeJS.ErrnoException).code;
187
+ if (code === "ENOENT") return `${object.name} is missing`;
188
+ // A refused open is already authoritative; this only labels it. Linux
189
+ // answers a no-follow directory open on a symlink with ENOTDIR rather
190
+ // than ELOOP, so the entry kind decides between the two labels.
191
+ if (code === "ELOOP" || code === "ENOTDIR") {
192
+ if (isSymbolicLink(pathname))
193
+ return `${object.name} is a symlink; every publication object is opened no-follow`;
194
+ if (code === "ENOTDIR" && object.directory) return `${object.name} is not a directory`;
195
+ }
196
+ if (code === "EISDIR") return `${object.name} is not a regular file`;
197
+ return `${object.name} could not be opened (${code ?? "unknown error"})`;
198
+ }
199
+ try {
200
+ // The published record is the last precondition: the native layer edits
201
+ // the heartbeat in place and therefore requires a fixed-width field. It is
202
+ // read through this verified descriptor, never reopened by name.
203
+ if (object.write) return describeWithheldPublishedRecord(descriptor, fsSync.fstatSync(descriptor).size);
204
+ } catch (error) {
205
+ return `${object.name} could not be inspected (${(error as NodeJS.ErrnoException).code ?? "unknown error"})`;
206
+ } finally {
207
+ try {
208
+ fsSync.closeSync(descriptor);
209
+ } catch {
210
+ // Closing a diagnostic-only descriptor is best effort; the native refusal
211
+ // remains the authoritative error when this probe itself is inconclusive.
212
+ }
213
+ }
214
+ }
215
+ return undefined;
216
+ }
217
+
27
218
  function requireRetainedBrokerPublication(agentDir: string): NativeRetainedBrokerPublication {
28
219
  const retainBrokerPublication = nativeBrokerDiscovery().retainBrokerPublication;
29
220
  if (typeof retainBrokerPublication !== "function") {
30
221
  throw new Error("Loaded native bindings do not expose retained broker publication authority.");
31
222
  }
32
- return retainBrokerPublication(agentDir);
223
+ try {
224
+ return retainBrokerPublication(agentDir);
225
+ } catch (error) {
226
+ // Fail closed exactly as before; only name the condition on the way out.
227
+ const obstruction = nativeRetainedObstruction(error);
228
+ if (obstruction) {
229
+ throw new Error(
230
+ `${error instanceof Error ? error.message : String(error)} (${obstruction}; agent directory ${sanitizedAgentDir(agentDir)})`,
231
+ { cause: error },
232
+ );
233
+ }
234
+ const observed = describeWithheldPublicationAuthority(agentDir);
235
+ if (!observed) throw error;
236
+ // The obstruction precedes the agent directory, and the directory is
237
+ // bounded, so a long valid path cannot truncate the named object away.
238
+ throw new Error(
239
+ `${error instanceof Error ? error.message : String(error)} (current observed state: ${observed}; agent directory ${sanitizedAgentDir(agentDir)})`,
240
+ { cause: error },
241
+ );
242
+ }
33
243
  }
34
244
 
35
245
  async function rollbackPublishedBrokerDiscovery(agentDir: string, discovery: BrokerDiscovery): Promise<void> {
@@ -216,9 +426,14 @@ export async function publishBrokerDiscovery(
216
426
  try {
217
427
  await rollbackPublishedBrokerDiscovery(agentDir, published);
218
428
  } catch (rollbackError) {
429
+ // The aggregate message is the only thing the durable startup-failure
430
+ // marker persists (`AggregateError.errors` is not serialized), so the named
431
+ // obstruction has to survive into it rather than only into `errors[0]`.
219
432
  throw new AggregateError(
220
433
  [error, rollbackError],
221
- "Broker publication authority acquisition and exact rollback both failed.",
434
+ `Broker publication authority acquisition and exact rollback both failed. Acquisition: ${
435
+ error instanceof Error ? error.message : String(error)
436
+ }`,
222
437
  );
223
438
  }
224
439
  throw error;