@gajae-code/coding-agent 0.16.7 → 0.17.1

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 (321) hide show
  1. package/CHANGELOG.md +180 -1
  2. package/dist/types/capability/fs.d.ts +37 -4
  3. package/dist/types/capability/index.d.ts +19 -0
  4. package/dist/types/capability/types.d.ts +9 -0
  5. package/dist/types/cli/doctor/args.d.ts +36 -0
  6. package/dist/types/cli/doctor/checks.d.ts +18 -0
  7. package/dist/types/cli/doctor/config-repairs.d.ts +38 -0
  8. package/dist/types/cli/doctor/context.d.ts +89 -0
  9. package/dist/types/cli/doctor/files.d.ts +27 -0
  10. package/dist/types/cli/doctor/ids.d.ts +20 -0
  11. package/dist/types/cli/doctor/install-repairs.d.ts +94 -0
  12. package/dist/types/cli/doctor/journal.d.ts +43 -0
  13. package/dist/types/cli/doctor/managed-link.d.ts +39 -0
  14. package/dist/types/cli/doctor/native-probe.d.ts +2 -0
  15. package/dist/types/cli/doctor/permission-repairs.d.ts +25 -0
  16. package/dist/types/cli/doctor/plugin-quarantine.d.ts +77 -0
  17. package/dist/types/cli/doctor/plugin-restore.d.ts +40 -0
  18. package/dist/types/cli/doctor/plugin-targets.d.ts +11 -0
  19. package/dist/types/cli/doctor/probe-client.d.ts +9 -0
  20. package/dist/types/cli/doctor/probe-types.d.ts +18 -0
  21. package/dist/types/cli/doctor/probe.d.ts +3 -0
  22. package/dist/types/cli/doctor/projection-probe.d.ts +2 -0
  23. package/dist/types/cli/doctor/repairs.d.ts +28 -0
  24. package/dist/types/cli/doctor/report.d.ts +27 -0
  25. package/dist/types/cli/doctor/runner.d.ts +4 -0
  26. package/dist/types/cli/doctor/safety.d.ts +10 -0
  27. package/dist/types/cli/doctor/service-repairs.d.ts +8 -0
  28. package/dist/types/cli/doctor/service-targets.d.ts +41 -0
  29. package/dist/types/cli/doctor/types.d.ts +145 -0
  30. package/dist/types/cli/doctor-cli.d.ts +7 -0
  31. package/dist/types/cli/doctor-supervisor.d.ts +121 -0
  32. package/dist/types/cli/doctor-worker.d.ts +29 -0
  33. package/dist/types/cli/install-activation.d.ts +158 -0
  34. package/dist/types/cli/macos-community-app.d.ts +41 -0
  35. package/dist/types/cli/mcp-cli.d.ts +0 -11
  36. package/dist/types/cli/update-cli.d.ts +41 -4
  37. package/dist/types/cli-main.d.ts +174 -0
  38. package/dist/types/cli.d.ts +1 -171
  39. package/dist/types/commands/doctor.d.ts +7 -0
  40. package/dist/types/commands/sdk.d.ts +17 -8
  41. package/dist/types/config/autorouting-tier-map.d.ts +19 -0
  42. package/dist/types/config/file-lock.d.ts +27 -3
  43. package/dist/types/config/model-preset-registry.d.ts +1 -0
  44. package/dist/types/config/model-registry.d.ts +12 -5
  45. package/dist/types/config/model-resolver.d.ts +16 -3
  46. package/dist/types/config/models-config-schema.d.ts +2 -0
  47. package/dist/types/config/settings-schema.d.ts +1 -1
  48. package/dist/types/coordinator-mcp/question-gate-codec.d.ts +1 -1
  49. package/dist/types/coordinator-mcp/session-reaper.d.ts +15 -5
  50. package/dist/types/cursor.d.ts +9 -9
  51. package/dist/types/discovery/agents-md.d.ts +6 -1
  52. package/dist/types/discovery/helpers.d.ts +36 -7
  53. package/dist/types/discovery/index.d.ts +1 -1
  54. package/dist/types/discovery/mcp-json.d.ts +2 -0
  55. package/dist/types/extensibility/extensions/loader.d.ts +22 -1
  56. package/dist/types/extensibility/gjc-plugins/lifecycle.d.ts +14 -1
  57. package/dist/types/extensibility/gjc-plugins/paths.d.ts +2 -1
  58. package/dist/types/extensibility/gjc-plugins/types.d.ts +25 -0
  59. package/dist/types/extensibility/plugins/manager.d.ts +33 -4
  60. package/dist/types/extensibility/plugins/marketplace/cache.d.ts +6 -0
  61. package/dist/types/extensibility/plugins/marketplace/manager.d.ts +186 -0
  62. package/dist/types/extensibility/plugins/marketplace/registry.d.ts +27 -0
  63. package/dist/types/extensibility/plugins/marketplace/source-resolver.d.ts +16 -1
  64. package/dist/types/extensibility/runtime-skill-discovery.d.ts +16 -3
  65. package/dist/types/gjc-runtime/autoresearch-runtime.d.ts +6 -0
  66. package/dist/types/gjc-runtime/launch-worktree.d.ts +53 -0
  67. package/dist/types/gjc-runtime/session-layout.d.ts +8 -0
  68. package/dist/types/gjc-runtime/session-state-lock.d.ts +15 -0
  69. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +13 -0
  70. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +6 -0
  71. package/dist/types/main.d.ts +19 -1
  72. package/dist/types/modes/acp/acp-agent.d.ts +40 -0
  73. package/dist/types/modes/components/agent-dashboard.d.ts +1 -0
  74. package/dist/types/modes/components/model-selector.d.ts +2 -0
  75. package/dist/types/modes/interactive-mode.d.ts +1 -1
  76. package/dist/types/runtime-mcp/config-writer.d.ts +47 -0
  77. package/dist/types/runtime-mcp/policy-json-edit.d.ts +60 -0
  78. package/dist/types/runtime-mcp/startup-policy.d.ts +4 -0
  79. package/dist/types/runtime-mcp/types.d.ts +2 -2
  80. package/dist/types/sdk/acp/adapter.d.ts +5 -1
  81. package/dist/types/sdk/broker/broker.d.ts +51 -0
  82. package/dist/types/sdk/broker/daemon-entry.d.ts +39 -0
  83. package/dist/types/sdk/broker/discovery.d.ts +38 -0
  84. package/dist/types/sdk/broker/doctor-restart.d.ts +79 -0
  85. package/dist/types/sdk/broker/endpoint-authority.d.ts +7 -0
  86. package/dist/types/sdk/broker/ensure.d.ts +14 -0
  87. package/dist/types/sdk/broker/lifecycle.d.ts +2 -0
  88. package/dist/types/sdk/broker/process-incarnation.d.ts +50 -0
  89. package/dist/types/sdk/broker/runtime.d.ts +26 -0
  90. package/dist/types/sdk/broker/session-index.d.ts +13 -4
  91. package/dist/types/sdk/broker/worktree-occupancy.d.ts +14 -0
  92. package/dist/types/sdk/bus/chat-daemon-cli.d.ts +16 -0
  93. package/dist/types/sdk/bus/chat-daemon-control.d.ts +101 -6
  94. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +16 -0
  95. package/dist/types/sdk/bus/daemon-startup-exclusion.d.ts +43 -0
  96. package/dist/types/sdk/bus/doctor-daemon-restart-client.d.ts +51 -0
  97. package/dist/types/sdk/bus/doctor-daemon-restart.d.ts +39 -0
  98. package/dist/types/sdk/bus/index.d.ts +2 -0
  99. package/dist/types/sdk/bus/notification-service.d.ts +3 -0
  100. package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +11 -0
  101. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  102. package/dist/types/sdk/bus/telegram-daemon-control.d.ts +5 -0
  103. package/dist/types/sdk/bus/telegram-daemon-owner-registry.d.ts +1 -0
  104. package/dist/types/sdk/bus/telegram-daemon.d.ts +36 -5
  105. package/dist/types/sdk/cli/session-cli.d.ts +1 -0
  106. package/dist/types/sdk/host/session-runtime.d.ts +4 -0
  107. package/dist/types/sdk/host/surface-policy.d.ts +3 -1
  108. package/dist/types/sdk/lifecycle/service.d.ts +44 -10
  109. package/dist/types/sdk/prompt-deadline-lease.d.ts +6 -3
  110. package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -2
  111. package/dist/types/sdk/prompt-failure.d.ts +47 -0
  112. package/dist/types/sdk/prompt-status.d.ts +19 -0
  113. package/dist/types/sdk/router/session-router.d.ts +3 -4
  114. package/dist/types/sdk/service-artifact-paths.d.ts +16 -0
  115. package/dist/types/sdk/session-authority.d.ts +9 -0
  116. package/dist/types/sdk/session.d.ts +3 -1
  117. package/dist/types/session/agent-session.d.ts +1 -1
  118. package/dist/types/session/blob-store.d.ts +1 -1
  119. package/dist/types/session/fallback-chain-controller.d.ts +3 -6
  120. package/dist/types/session/internal/managed-session-scope.d.ts +44 -0
  121. package/dist/types/session/primary-control-surface.d.ts +4 -0
  122. package/dist/types/session/session-manager.d.ts +6 -0
  123. package/dist/types/system-prompt.d.ts +6 -0
  124. package/dist/types/tools/ask.d.ts +25 -0
  125. package/dist/types/tools/image-gen.d.ts +0 -4
  126. package/dist/types/tools/skill-discovery.d.ts +7 -5
  127. package/dist/types/tools/ultragoal-ask-guard.d.ts +7 -4
  128. package/dist/types/utils/changelog.d.ts +0 -9
  129. package/dist/types/utils/mupdf-wasm.d.ts +1 -0
  130. package/package.json +9 -8
  131. package/scripts/compile-args.ts +8 -4
  132. package/src/capability/fs.ts +395 -17
  133. package/src/capability/index.ts +273 -10
  134. package/src/capability/types.ts +9 -0
  135. package/src/cli/customize-doctor.ts +18 -12
  136. package/src/cli/doctor/args.ts +236 -0
  137. package/src/cli/doctor/checks.ts +431 -0
  138. package/src/cli/doctor/config-repairs.ts +517 -0
  139. package/src/cli/doctor/context.ts +149 -0
  140. package/src/cli/doctor/files.ts +149 -0
  141. package/src/cli/doctor/ids.ts +115 -0
  142. package/src/cli/doctor/install-repairs.ts +335 -0
  143. package/src/cli/doctor/journal.ts +423 -0
  144. package/src/cli/doctor/managed-link.ts +514 -0
  145. package/src/cli/doctor/native-probe.ts +13 -0
  146. package/src/cli/doctor/permission-repairs.ts +242 -0
  147. package/src/cli/doctor/plugin-quarantine.ts +377 -0
  148. package/src/cli/doctor/plugin-restore.ts +59 -0
  149. package/src/cli/doctor/plugin-targets.ts +373 -0
  150. package/src/cli/doctor/probe-client.ts +195 -0
  151. package/src/cli/doctor/probe-types.ts +21 -0
  152. package/src/cli/doctor/probe.ts +42 -0
  153. package/src/cli/doctor/projection-probe.ts +249 -0
  154. package/src/cli/doctor/repairs.ts +1157 -0
  155. package/src/cli/doctor/report.ts +176 -0
  156. package/src/cli/doctor/runner.ts +342 -0
  157. package/src/cli/doctor/safety.ts +40 -0
  158. package/src/cli/doctor/service-repairs.ts +452 -0
  159. package/src/cli/doctor/service-targets.ts +274 -0
  160. package/src/cli/doctor/types.ts +164 -0
  161. package/src/cli/doctor-cli.ts +62 -0
  162. package/src/cli/doctor-supervisor.ts +571 -0
  163. package/src/cli/doctor-worker.ts +181 -0
  164. package/src/cli/install-activation.ts +666 -0
  165. package/src/cli/macos-community-app.ts +1449 -0
  166. package/src/cli/mcp-cli.ts +11 -21
  167. package/src/cli/nofile-limit.ts +5 -1
  168. package/src/cli/plugin-cli.ts +6 -1
  169. package/src/cli/update-cli.ts +362 -264
  170. package/src/cli-main.ts +517 -0
  171. package/src/cli.ts +64 -433
  172. package/src/commands/autoresearch.ts +11 -5
  173. package/src/commands/completion.ts +1 -1
  174. package/src/commands/doctor.ts +13 -0
  175. package/src/commands/sdk.ts +58 -10
  176. package/src/commands/ultragoal.ts +11 -5
  177. package/src/config/autorouting-tier-map.ts +7 -0
  178. package/src/config/file-lock.ts +736 -71
  179. package/src/config/mcp-schema.json +3 -3
  180. package/src/config/model-preset-registry.ts +1 -0
  181. package/src/config/model-registry.ts +80 -27
  182. package/src/config/model-resolver.ts +33 -4
  183. package/src/config/models-config-schema.ts +2 -0
  184. package/src/config/settings-schema.ts +6 -2
  185. package/src/coordinator-mcp/question-gate-codec.ts +1 -0
  186. package/src/coordinator-mcp/server.ts +534 -51
  187. package/src/coordinator-mcp/session-reaper.ts +64 -8
  188. package/src/cursor.ts +223 -53
  189. package/src/discovery/agents-md.ts +74 -8
  190. package/src/discovery/agents.ts +32 -7
  191. package/src/discovery/builtin.ts +184 -75
  192. package/src/discovery/claude-plugins.ts +85 -17
  193. package/src/discovery/cline.ts +38 -10
  194. package/src/discovery/cursor.ts +19 -6
  195. package/src/discovery/gemini.ts +62 -22
  196. package/src/discovery/github.ts +13 -3
  197. package/src/discovery/helpers.ts +483 -89
  198. package/src/discovery/index.ts +1 -0
  199. package/src/discovery/mcp-json.ts +36 -10
  200. package/src/discovery/opencode.ts +36 -19
  201. package/src/discovery/ssh.ts +11 -8
  202. package/src/discovery/windsurf.ts +12 -3
  203. package/src/exec/bash-executor.ts +10 -3
  204. package/src/exec/isolated-shell.ts +15 -2
  205. package/src/extensibility/extensions/loader.ts +88 -26
  206. package/src/extensibility/gjc-plugins/lifecycle.ts +189 -0
  207. package/src/extensibility/gjc-plugins/paths.ts +4 -1
  208. package/src/extensibility/gjc-plugins/types.ts +27 -0
  209. package/src/extensibility/plugins/manager.ts +200 -22
  210. package/src/extensibility/plugins/marketplace/cache.ts +55 -2
  211. package/src/extensibility/plugins/marketplace/manager.ts +1262 -162
  212. package/src/extensibility/plugins/marketplace/registry.ts +153 -0
  213. package/src/extensibility/plugins/marketplace/source-resolver.ts +116 -10
  214. package/src/extensibility/runtime-skill-discovery.ts +61 -9
  215. package/src/extensibility/skill-management.ts +7 -1
  216. package/src/gjc-runtime/autoresearch-runtime.ts +10 -1
  217. package/src/gjc-runtime/launch-worktree.test.ts +248 -0
  218. package/src/gjc-runtime/launch-worktree.ts +275 -38
  219. package/src/gjc-runtime/repository-binding.ts +26 -1
  220. package/src/gjc-runtime/session-layout.ts +10 -0
  221. package/src/gjc-runtime/session-state-lock.ts +330 -23
  222. package/src/gjc-runtime/session-state-sidecar.ts +328 -80
  223. package/src/gjc-runtime/ultragoal-runtime.ts +10 -1
  224. package/src/harness-control-plane/session-lease.ts +2 -2
  225. package/src/internal-urls/docs-index.generated.ts +28 -26
  226. package/src/main.ts +150 -29
  227. package/src/memories/index.ts +24 -2
  228. package/src/modes/acp/acp-agent.ts +522 -68
  229. package/src/modes/components/agent-dashboard.ts +3 -1
  230. package/src/modes/components/model-selector.ts +28 -19
  231. package/src/modes/components/plugin-settings.ts +2 -1
  232. package/src/modes/controllers/selector-controller.ts +15 -9
  233. package/src/modes/interactive-mode.ts +21 -3
  234. package/src/prompts/system/system-prompt.md +5 -1
  235. package/src/prompts/tools/browser.md +1 -1
  236. package/src/prompts/tools/move-session.md +4 -0
  237. package/src/prompts/tools/read.md +2 -1
  238. package/src/prompts/tools/skill-discovery.md +6 -5
  239. package/src/prompts/tools/task.md +1 -0
  240. package/src/runtime-mcp/config-writer.ts +228 -0
  241. package/src/runtime-mcp/oauth-discovery.ts +114 -14
  242. package/src/runtime-mcp/policy-json-edit.ts +129 -0
  243. package/src/runtime-mcp/startup-policy.ts +14 -0
  244. package/src/runtime-mcp/types.ts +2 -2
  245. package/src/sdk/acp/adapter.ts +27 -12
  246. package/src/sdk/broker/broker.ts +273 -44
  247. package/src/sdk/broker/daemon-entry.ts +119 -0
  248. package/src/sdk/broker/discovery.ts +173 -20
  249. package/src/sdk/broker/doctor-restart.ts +206 -0
  250. package/src/sdk/broker/endpoint-authority.ts +36 -0
  251. package/src/sdk/broker/ensure.ts +49 -14
  252. package/src/sdk/broker/lifecycle.ts +174 -42
  253. package/src/sdk/broker/process-incarnation.ts +86 -2
  254. package/src/sdk/broker/runtime.ts +145 -21
  255. package/src/sdk/broker/session-index.ts +136 -22
  256. package/src/sdk/broker/spawn-substrate.ts +4 -1
  257. package/src/sdk/broker/transport.ts +5 -0
  258. package/src/sdk/broker/worktree-occupancy.ts +21 -1
  259. package/src/sdk/bus/chat-daemon-cli.ts +63 -0
  260. package/src/sdk/bus/chat-daemon-control.ts +260 -64
  261. package/src/sdk/bus/chat-daemon-runtime.ts +141 -25
  262. package/src/sdk/bus/daemon-startup-exclusion.ts +119 -0
  263. package/src/sdk/bus/doctor-daemon-restart-client.ts +291 -0
  264. package/src/sdk/bus/doctor-daemon-restart.ts +92 -0
  265. package/src/sdk/bus/index.ts +252 -51
  266. package/src/sdk/bus/kind-aware-reconciliation.ts +76 -41
  267. package/src/sdk/bus/notification-service.ts +21 -3
  268. package/src/sdk/bus/reconciliation-store.ts +17 -11
  269. package/src/sdk/bus/telegram-daemon-cli.ts +91 -5
  270. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  271. package/src/sdk/bus/telegram-daemon-control.ts +47 -0
  272. package/src/sdk/bus/telegram-daemon-owner-registry.ts +1 -1
  273. package/src/sdk/bus/telegram-daemon.ts +178 -13
  274. package/src/sdk/cli/session-cli.ts +100 -12
  275. package/src/sdk/host/host.ts +2 -7
  276. package/src/sdk/host/session-runtime.test.ts +329 -8
  277. package/src/sdk/host/session-runtime.ts +202 -50
  278. package/src/sdk/host/surface-policy.ts +8 -1
  279. package/src/sdk/lifecycle/service.ts +199 -22
  280. package/src/sdk/mcp/server.ts +1 -1
  281. package/src/sdk/prompt-deadline-lease.ts +12 -4
  282. package/src/sdk/prompt-deadline-manager.ts +5 -7
  283. package/src/sdk/prompt-failure.ts +148 -0
  284. package/src/sdk/prompt-status.ts +30 -1
  285. package/src/sdk/prompt-watchdog.ts +11 -3
  286. package/src/sdk/protocol/operation-inventory.generated.json +18 -0
  287. package/src/sdk/protocol/operation-registry.ts +20 -8
  288. package/src/sdk/router/session-router.ts +8 -3
  289. package/src/sdk/service-artifact-paths.ts +26 -0
  290. package/src/sdk/session-authority.ts +10 -0
  291. package/src/sdk/session.ts +361 -194
  292. package/src/session/agent-session.ts +398 -93
  293. package/src/session/blob-store.ts +9 -9
  294. package/src/session/fallback-chain-controller.ts +3 -7
  295. package/src/session/internal/managed-session-scope.ts +445 -29
  296. package/src/session/internal/managed-session-storage.ts +38 -6
  297. package/src/session/primary-control-surface.ts +12 -0
  298. package/src/session/session-manager.ts +11 -3
  299. package/src/session/session-storage.ts +1 -1
  300. package/src/session-import/codex.ts +6 -1
  301. package/src/session-import/redact.ts +7 -0
  302. package/src/skill-state/workflow-mutation-guard.ts +645 -39
  303. package/src/slash-commands/builtin-registry.ts +7 -3
  304. package/src/system-prompt.ts +8 -0
  305. package/src/task/executor.ts +36 -3
  306. package/src/task/types.ts +27 -5
  307. package/src/tools/ask.ts +114 -34
  308. package/src/tools/browser/actions.ts +2 -2
  309. package/src/tools/browser/tab-worker.ts +11 -8
  310. package/src/tools/descriptors.ts +6 -1
  311. package/src/tools/fetch.ts +7 -2
  312. package/src/tools/image-gen.ts +33 -34
  313. package/src/tools/read.ts +78 -32
  314. package/src/tools/skill-discovery.ts +17 -7
  315. package/src/tools/tool-catalog.generated.ts +8 -8
  316. package/src/tools/ultragoal-ask-guard.ts +16 -26
  317. package/src/utils/changelog.ts +7 -1
  318. package/src/utils/git.ts +3 -1
  319. package/src/utils/markit.ts +24 -2
  320. package/src/utils/mupdf-wasm.ts +26 -0
  321. package/src/web/search/providers/openai-compatible.ts +191 -14
@@ -47,12 +47,13 @@ import {
47
47
  acpMcpLaunchFailure,
48
48
  } from "../../sdk/acp";
49
49
  import { resolveAcpFinalText } from "../../sdk/acp/final-text";
50
- import { ACP_MCP_LIFECYCLE_TIMEOUT_MS, type SessionLifecycleMcpServer } from "../../sdk/acp/mcp";
50
+ import type { SessionLifecycleMcpServer } from "../../sdk/acp/mcp";
51
51
  import { ensureBroker } from "../../sdk/broker/ensure";
52
52
  import { canonicalSessionCwd } from "../../sdk/broker/session-index";
53
53
  import { readSdkBrokerDiscovery, SdkClient, SdkClientError } from "../../sdk/client";
54
54
  import type { AbortScope } from "../../sdk/host/control/operations";
55
55
  import { SYNTHETIC_PROVIDER_ID } from "../../sdk/model-profile-namespace";
56
+ import { failedPromptOutcome, isSdkPromptFailurePhase, rephaseFailedOutcome } from "../../sdk/prompt-failure";
56
57
  import type { SdkPromptTerminalOutcome } from "../../sdk/prompt-status";
57
58
  import { PromptActivity, type PromptWatchdogClock, systemPromptWatchdogClock } from "../../sdk/prompt-watchdog";
58
59
  import { validateRequiredPromptText } from "../../sdk/protocol/adapter-validation";
@@ -101,16 +102,53 @@ const MAX_PROMPT_FRAME_BYTES = 256 * 1024;
101
102
  * envelope. A canonical-length UUID keeps the measurement equal to the real frame.
102
103
  */
103
104
  const PROMPT_FRAME_ID_PLACEHOLDER = "00000000-0000-4000-8000-000000000000";
105
+ /**
106
+ * Wall-clock ceiling an external ACP client grants a `session/new` before it
107
+ * abandons the connection (paseo's daemon uses 60s). gjc must return a
108
+ * `session.create` response — a launched session or a terminal launch error —
109
+ * inside this window, or the client reports a dead provider while gjc is still
110
+ * healthily spawning the host. See issue #5565.
111
+ */
112
+ export const ACP_EXTERNAL_CONNECT_TIMEOUT_MS = 60_000;
104
113
  /**
105
114
  * Readiness budget every ACP session launch requests, independent of MCP.
106
115
  *
107
- * Host cold start costs the same whether or not the client declared MCP servers,
108
- * so leaving the MCP-less path on the broker's 10s default made it the only ACP
109
- * launch that fails under ordinary concurrency: a second host starting beside the
110
- * first measurably crosses that deadline, and the broker then reports the launch
111
- * as `terminal_uncertain`, which an external client surfaces as a dead provider.
116
+ * Two constraints bound this value:
117
+ *
118
+ * 1. Floor — host cold start costs the same whether or not the client declared
119
+ * MCP servers, so leaving the MCP-less path on the broker's 10s default made
120
+ * it the only ACP launch that failed under ordinary concurrency: a second
121
+ * host starting beside the first measurably crosses that deadline, the broker
122
+ * reports the launch as `terminal_uncertain`, and an external client surfaces
123
+ * a dead provider.
124
+ * 2. Ceiling — the broker may park a startup in its admission queue for a whole
125
+ * readiness budget before the readiness clock even starts, so the ACP adapter
126
+ * sizes its client-side wait at `lifecycleStartupBudgetMs(R)` = queue wait +
127
+ * readiness = `2·R`, plus caller slack. With R = 30.5s that wait reached ~62s
128
+ * — past paseo's 60s connect timeout — so a queued or slow-cold-start launch
129
+ * let paseo abandon the connection before gjc could answer `session/new` at
130
+ * all (#5565). R must keep `2·R + slack` safely under
131
+ * {@link ACP_EXTERNAL_CONNECT_TIMEOUT_MS}.
132
+ *
133
+ * 22s satisfies both: comfortably above the ~16s cold starts observed on slow
134
+ * hosts (and the 10s concurrency floor) while holding the client-side lifecycle
135
+ * budget near 45s — a ~15s margin under the 60s ceiling.
136
+ */
137
+ export const ACP_SESSION_READINESS_TIMEOUT_MS = 22_000;
138
+
139
+ /**
140
+ * A freshly created session host can accept the first turn before it has finished
141
+ * coming up (provider stream, tool registry, MCP wiring), so the very first prompt
142
+ * can terminalize as `prompt_failed` — surfaced to an ACP client as an opaque
143
+ * `-32603` with no usable result (issue #5574). The failed turn produced no answer,
144
+ * so re-submitting the same first prompt a bounded number of times recovers the
145
+ * startup race instead of ending the session on its first turn. Only the first
146
+ * logical prompt of a session retries, and only an `agent_failed` `prompt_failed`
147
+ * terminal (never a deadline, a client cancel, or a clean stop) is retried.
112
148
  */
113
- const ACP_SESSION_READINESS_TIMEOUT_MS = ACP_MCP_LIFECYCLE_TIMEOUT_MS;
149
+ const ACP_FIRST_PROMPT_MAX_RETRIES = 2;
150
+ /** Backoff before each first-prompt retry, scaled by attempt (250ms, then 500ms). */
151
+ const ACP_FIRST_PROMPT_RETRY_BASE_DELAY_MS = 250;
114
152
 
115
153
  type JsonObject = Record<string, unknown>;
116
154
  interface PromptWaiter {
@@ -143,6 +181,10 @@ interface PromptWaiter {
143
181
  cancelWatchdog?: () => void;
144
182
  /** What the host is observably doing — a tool running, a model call unanswered — and the bound that follows from it. */
145
183
  activity: PromptActivity;
184
+ /** True once a prompt-owned progress frame (agent_start / message / tool) was observed; gates first-turn retries. */
185
+ observedTurnActivity: boolean;
186
+ /** True once a prompt-owned tool execution frame was observed; vetoes a first-turn retry that would re-run it. */
187
+ observedToolExecution: boolean;
146
188
  /** Coordinates a prompt-control rejection racing an acknowledged ACP cancellation. */
147
189
  cancelAttempt?: Promise<boolean>;
148
190
  /** Whether ANY overlapping cancel attempt for this prompt was acknowledged:
@@ -169,6 +211,7 @@ type PendingAttachment = { epoch: number; task: Promise<void> };
169
211
  type SessionRecord = {
170
212
  cwd: string;
171
213
  adapter: AcpSdkAdapter;
214
+ primaryControlSurface: "cli" | "sdk";
172
215
  attachment: SessionAttachment;
173
216
  closeIdempotencyKey: string;
174
217
  unsubscribe: () => void;
@@ -198,8 +241,78 @@ type SessionRecord = {
198
241
  activePrompt?: PromptWaiter;
199
242
  /** Set by `session/cancel` so an in-flight prompt settles as `cancelled`, never as an error. */
200
243
  cancelRequested?: boolean;
244
+ /** True once the session's first logical prompt has settled; gates first-turn readiness retries. */
245
+ firstPromptDone?: boolean;
246
+ /** Whether the current prompt attempt was observed doing work; reset per attempt, read by the first-turn retry gate. */
247
+ promptObservedActivity?: boolean;
248
+ /** Whether the current prompt attempt executed a tool; reset per attempt, vetoes a first-turn retry that would re-run it. */
249
+ promptObservedToolExecution?: boolean;
250
+ /** Whether the current prompt attempt published assistant text/thought output; reset per attempt, vetoes a first-turn retry that would re-emit it. */
251
+ promptObservedAssistantOutput?: boolean;
252
+ /** Owner token held across the first-turn retry backoff; a non-owner prompt is rejected as a conflict (review P1). */
253
+ pendingFirstPromptRetry?: FirstPromptRetryReservation;
201
254
  };
202
255
 
256
+ /**
257
+ * Identity token proving a caller owns an in-progress first-turn retry. While it is held on the
258
+ * session record, `#submitPrompt` rejects any prompt that is not this owner, so a concurrent
259
+ * request cannot slip into the gap between `#settlePrompt` clearing `activePrompt` on the failed
260
+ * first attempt and the retry resubmitting after its backoff (review P1, issue #5574).
261
+ */
262
+ type FirstPromptRetryReservation = {
263
+ readonly firstPromptRetry: true;
264
+ /**
265
+ * Set once `#submitPrompt` dispatched a turn for this prompt. Distinguishes a prompt that
266
+ * genuinely owned the session's first turn from a preflight rejection, so only the former
267
+ * settles `firstPromptDone` (review P2).
268
+ */
269
+ admitted?: boolean;
270
+ /**
271
+ * Terminal settlement recorded by a teardown that won the backoff gap. The failed attempt
272
+ * already cleared `activePrompt`, so `#teardownSession` has no waiter to settle for this
273
+ * caller and nothing else carries the outcome across the sleep. Without it the retry wakes
274
+ * to a deleted record and resubmits into `not_found`, where a client-driven close/delete
275
+ * owes the ACP `cancelled` stop reason (review P1).
276
+ */
277
+ settlement?: { kind: "cancelled" } | { kind: "rejected"; error: AcpSdkAdapterError };
278
+ };
279
+
280
+ /**
281
+ * Prompt-owned frame types that prove the turn is underway (as opposed to a
282
+ * terminal or a bare failure diagnostic). Observing one is the first-turn
283
+ * readiness-race fingerprint the retry gate keys on.
284
+ */
285
+ function isTurnProgressEventType(type: string | undefined): boolean {
286
+ switch (type) {
287
+ case "agent_start":
288
+ case "message_start":
289
+ case "message_update":
290
+ case "message_end":
291
+ case "tool_execution_start":
292
+ case "tool_execution_update":
293
+ case "tool_execution_end":
294
+ return true;
295
+ default:
296
+ return false;
297
+ }
298
+ }
299
+
300
+ /**
301
+ * Prompt-owned frame types that prove a tool actually executed. A turn that ran a tool did
302
+ * side-effecting work, so it must never be re-run by the first-turn readiness retry (issue
303
+ * #5574 / review P1): a re-submit is an independent turn that would repeat those side effects.
304
+ */
305
+ function isTurnToolExecutionEventType(type: string | undefined): boolean {
306
+ switch (type) {
307
+ case "tool_execution_start":
308
+ case "tool_execution_update":
309
+ case "tool_execution_end":
310
+ return true;
311
+ default:
312
+ return false;
313
+ }
314
+ }
315
+
203
316
  function promptWaiterRetired(record: SessionRecord, waiter: PromptWaiter): boolean {
204
317
  return waiter.settled || record.activePrompt !== waiter;
205
318
  }
@@ -496,6 +609,45 @@ function logDroppedPromptTerminal(
496
609
  });
497
610
  }
498
611
 
612
+ type SdkPromptFailedOutcome = Extract<SdkPromptTerminalOutcome, { kind: "failed" }>;
613
+
614
+ /**
615
+ * A prompt rejection that still carries the terminal's structured failure classification.
616
+ * The ACP client sees only the code and message, but the first-turn retry gate must decide on
617
+ * what the terminal actually proved — the failure's execution phase and its bounded origin
618
+ * category — so settlement carries that classification instead of discarding it into a bare
619
+ * `AcpSdkAdapterError` (review P1, issue #5574).
620
+ */
621
+ class AcpPromptFailureError extends AcpSdkAdapterError {
622
+ readonly failure: SdkPromptFailedOutcome;
623
+ constructor(failure: SdkPromptFailedOutcome) {
624
+ super(failure.code, failure.message);
625
+ this.failure = failure;
626
+ }
627
+ }
628
+
629
+ /**
630
+ * The startup-readiness failure class (issue #5574): the turn failed AFTER it started, with a
631
+ * bounded provider/transport classifier — the signature of a host that accepted the turn before
632
+ * its provider stream had finished coming up.
633
+ *
634
+ * Every other failure a `prompt_failed` terminal can carry is a genuine failure of this turn and
635
+ * is surfaced rather than re-submitted, even when it happened to publish nothing: a provider
636
+ * rejection (quota, refusal, 4xx), an agent runtime failure, a submission-phase rejection, or a
637
+ * deadline. Without this class check, "started, no output" alone authorized a retry, so any such
638
+ * rejection was silently re-run as a second turn (review P1).
639
+ */
640
+ function isStartupReadinessFailure(error: unknown): boolean {
641
+ if (!(error instanceof AcpPromptFailureError)) return false;
642
+ const { failure } = error;
643
+ return (
644
+ failure.code === "prompt_failed" &&
645
+ failure.provenance === "agent_failed" &&
646
+ failure.phase === "post_start" &&
647
+ failure.category === "provider_transport"
648
+ );
649
+ }
650
+
499
651
  function terminalOutcome(event: JsonObject): SdkPromptTerminalOutcome | undefined {
500
652
  const outcome = object(event.outcome);
501
653
  if (!outcome) return undefined;
@@ -514,13 +666,15 @@ function terminalOutcome(event: JsonObject): SdkPromptTerminalOutcome | undefine
514
666
  (outcome.code === "prompt_failed" || outcome.code === "prompt_deadline_exceeded") &&
515
667
  typeof outcome.message === "string" &&
516
668
  (outcome.provenance === "agent_failed" || outcome.provenance === "deadline")
517
- )
518
- return {
519
- kind: "failed",
669
+ ) {
670
+ return failedPromptOutcome({
520
671
  code: outcome.code,
521
- message: outcome.message,
522
672
  provenance: outcome.provenance,
523
- };
673
+ ...(typeof outcome.providerCode === "string" ? { providerCode: outcome.providerCode } : {}),
674
+ ...(isSdkPromptFailurePhase(outcome.phase) ? { phase: outcome.phase } : {}),
675
+ evidence: {},
676
+ });
677
+ }
524
678
  return undefined;
525
679
  }
526
680
 
@@ -756,6 +910,22 @@ function configValues(query: unknown): Map<string, string> {
756
910
  return values;
757
911
  }
758
912
 
913
+ /**
914
+ * Recover a session-scoped preset only when the host's current model carries
915
+ * the synthetic profile namespace. A plain `modelPreset` value may be the
916
+ * persisted default and is therefore insufficient to identify an active
917
+ * profile, especially when attaching through the base ACP provider.
918
+ */
919
+ export function activeModelPresetFromConfig(query: unknown): string | undefined {
920
+ const values = configValues(query);
921
+ const model = values.get(MODEL_CONFIG_ID);
922
+ const preset = values.get(MODEL_PRESET_CONFIG_KEY);
923
+ const prefix = `${SYNTHETIC_PROVIDER_ID}/`;
924
+ if (!model?.startsWith(prefix) || !preset) return undefined;
925
+ const syntheticPreset = model.slice(prefix.length);
926
+ return syntheticPreset === preset ? syntheticPreset : undefined;
927
+ }
928
+
759
929
  function modelPresetConfigOptions(query: unknown, current: string): { value: string; name: string }[] {
760
930
  const options = new Map<string, string>();
761
931
  for (const item of pageItems(query)) {
@@ -1380,8 +1550,11 @@ export class AcpAgent implements Agent {
1380
1550
  );
1381
1551
  const id = sessionId(result);
1382
1552
  this.#knownSessionCwds.set(id, params.cwd);
1383
- this.#ownedSessionIds.add(id);
1384
1553
  this.#knownSessionMcpServers.set(id, mcpServers);
1554
+ // This connection launched the host, so it owns the broker lifecycle before the
1555
+ // attachment reports a control surface. Without it a failed attach discards the
1556
+ // session as unowned and masks the real error with cleanup uncertainty.
1557
+ this.#ownedSessionIds.add(id);
1385
1558
  try {
1386
1559
  await this.#attach(id, params.cwd, undefined, result);
1387
1560
  await applyAcpStartupOptions(this.#adapter(id), this.#startupOptions);
@@ -1437,8 +1610,10 @@ export class AcpAgent implements Agent {
1437
1610
  );
1438
1611
  const id = sessionId(result);
1439
1612
  this.#knownSessionCwds.set(id, params.cwd);
1440
- this.#ownedSessionIds.add(id);
1441
1613
  this.#knownSessionMcpServers.set(id, mcpServers);
1614
+ // Forking launches a host through this connection, so lifecycle ownership is
1615
+ // established before attachment for the same reason as `newSession`.
1616
+ this.#ownedSessionIds.add(id);
1442
1617
  try {
1443
1618
  await this.#attach(id, params.cwd, undefined, result);
1444
1619
  const response = { sessionId: id, ...(await this.#sessionState(id)) };
@@ -1484,8 +1659,10 @@ export class AcpAgent implements Agent {
1484
1659
 
1485
1660
  closeSession(params: CloseSessionRequest): Promise<CloseSessionResponse> {
1486
1661
  const record = this.#sessions.get(params.sessionId);
1487
- // ACP close has no cwd. Only connection-owned sessions may reach broker lifecycle control.
1488
- if (!this.#ownedSessionIds.has(params.sessionId)) return Promise.resolve({});
1662
+ // ACP close has no cwd. Broker lifecycle control stays owner-only, but a live
1663
+ // local attachment is always released: a CLI-primary session the client closed
1664
+ // must not keep its adapter, lease heartbeat, subscriptions and pending prompt.
1665
+ if (!this.#ownedSessionIds.has(params.sessionId) && record === undefined) return Promise.resolve({});
1489
1666
  const cwd = record?.cwd ?? this.#knownSessionCwds.get(params.sessionId);
1490
1667
  if (!cwd) return Promise.resolve({});
1491
1668
  return this.#enqueueLifecycleOperation(params.sessionId, async () => {
@@ -1500,7 +1677,17 @@ export class AcpAgent implements Agent {
1500
1677
  const pendingLocator = this.#pendingDeleteLocators.get(params.sessionId);
1501
1678
  // Capture authority before joining the lifecycle chain: an admitted delete must
1502
1679
  // remain authorized when a preceding close retires connection ownership.
1503
- if (!this.#ownedSessionIds.has(params.sessionId) && pendingLocator === undefined) return {};
1680
+ if (!this.#ownedSessionIds.has(params.sessionId) && pendingLocator === undefined) {
1681
+ // Deleting a live session this connection does not own would claim a durable
1682
+ // deletion it never performs. Unknown ids stay the protocol no-op; a live
1683
+ // CLI-primary attachment is refused explicitly instead of faking success.
1684
+ if (record !== undefined)
1685
+ throw new AcpSdkAdapterError(
1686
+ "operation_prohibited",
1687
+ `ACP session ${params.sessionId} is owned by its terminal host; delete it from that session instead.`,
1688
+ );
1689
+ return {};
1690
+ }
1504
1691
  const cwd = record?.cwd ?? this.#knownSessionCwds.get(params.sessionId) ?? pendingLocator?.cwd;
1505
1692
  return this.#enqueueLifecycleOperation(params.sessionId, async () => {
1506
1693
  // ACP's delete request has no cwd. Unknown ids remain the protocol no-op,
@@ -1590,10 +1777,14 @@ export class AcpAgent implements Agent {
1590
1777
  await this.setSessionMode({ sessionId: params.sessionId, modeId: params.value });
1591
1778
  break;
1592
1779
  case MODEL_CONFIG_ID:
1593
- if (this.#startupOptions?.modelPreset === undefined) {
1594
- await this.#adapter(params.sessionId).setModel(params.value);
1595
- } else if (params.value !== ACP_CUSTOM_MODEL_PRESET) {
1596
- await this.#adapter(params.sessionId).control("model.profile.set", { id: params.value });
1780
+ {
1781
+ const config = await this.#adapter(params.sessionId).query("config.list/get");
1782
+ const activePreset = this.#startupOptions?.modelPreset ?? activeModelPresetFromConfig(config);
1783
+ if (activePreset === undefined) {
1784
+ await this.#adapter(params.sessionId).setModel(params.value);
1785
+ } else if (params.value !== ACP_CUSTOM_MODEL_PRESET) {
1786
+ await this.#adapter(params.sessionId).control("model.profile.set", { id: params.value });
1787
+ }
1597
1788
  }
1598
1789
  break;
1599
1790
  case THINKING_CONFIG_ID:
@@ -1614,8 +1805,168 @@ export class AcpAgent implements Agent {
1614
1805
  }
1615
1806
 
1616
1807
  async prompt(params: PromptRequest): Promise<PromptResponse> {
1808
+ const record = this.#sessions.get(params.sessionId);
1809
+ // An unknown session must still report not_found; the retry loop only guards a
1810
+ // live session's first turn against a startup readiness race (issue #5574).
1811
+ if (!record) return await this.#submitPrompt(params, true);
1812
+ // Identity of THIS caller's first-turn retry. Held on the record across the backoff so a
1813
+ // concurrent prompt racing the gap is rejected as a conflict rather than admitted (review P1).
1814
+ const retryReservation: FirstPromptRetryReservation = { firstPromptRetry: true };
1815
+ try {
1816
+ let echoUserMessage = true;
1817
+ for (let attempt = 0; ; attempt++) {
1818
+ try {
1819
+ return await this.#submitPrompt(params, echoUserMessage, retryReservation);
1820
+ } catch (error) {
1821
+ if (!this.#shouldRetryFirstPrompt(record, error, attempt)) throw error;
1822
+ // The user's message was already echoed on the first attempt; re-echoing
1823
+ // would duplicate it in the client transcript.
1824
+ echoUserMessage = false;
1825
+ // Reserve the session BEFORE the backoff sleep. `#settlePrompt` already cleared
1826
+ // `activePrompt` when it rejected this attempt, so without the reservation the
1827
+ // backoff gap admits a competing prompt that steals the first turn (review P1).
1828
+ record.pendingFirstPromptRetry = retryReservation;
1829
+ logger.warn("ACP first-turn prompt failed; retrying after readiness backoff", {
1830
+ sessionId: params.sessionId,
1831
+ attempt: attempt + 1,
1832
+ maxRetries: ACP_FIRST_PROMPT_MAX_RETRIES,
1833
+ });
1834
+ await this.#delayFirstPromptRetry(attempt);
1835
+ // The failed attempt publishes its final text and its failure diagnostic on async
1836
+ // tails, and `#submitPrompt` rejects any prompt while one of them is still in
1837
+ // flight. A slow or backpressured ACP client keeps a tail running well past a
1838
+ // 250/500ms backoff, so an authorized retry would lose its own turn to a
1839
+ // `conflict` raised by its own predecessor's publications. Wait the tails out
1840
+ // here instead of weakening those guards, which still have to reject prompts
1841
+ // racing in from other callers (review P1).
1842
+ await this.#drainPromptPublicationTails(params.sessionId);
1843
+ // A session/close or session/delete that won the backoff gap tore the record
1844
+ // down without a waiter to settle, leaving its outcome on this reservation.
1845
+ // Honor it here: resubmitting would only reach `#submitPrompt`'s not_found,
1846
+ // which is not what a client-driven teardown owes the caller (review P1).
1847
+ const settlement = retryReservation.settlement;
1848
+ if (settlement) {
1849
+ if (settlement.kind === "cancelled") return { stopReason: "cancelled" };
1850
+ throw settlement.error;
1851
+ }
1852
+ // A session/cancel that arrived during the backoff gap (activePrompt already
1853
+ // cleared by the failed attempt, retry not yet resubmitted) must settle the
1854
+ // retry as `cancelled` instead of dispatching a fresh turn the client no
1855
+ // longer wants. The check lives here, before resubmission, because
1856
+ // `#submitPrompt` unconditionally clears `cancelRequested` for the new turn
1857
+ // it is about to start, so a later check would miss it (review P1).
1858
+ if (record.cancelRequested) {
1859
+ record.cancelRequested = false;
1860
+ return { stopReason: "cancelled" };
1861
+ }
1862
+ }
1863
+ }
1864
+ } finally {
1865
+ // Release the reservation on retry completion (success), cancellation, or final
1866
+ // failure. Guarded so a later prompt that took ownership is never cleared by this caller.
1867
+ if (record.pendingFirstPromptRetry === retryReservation) record.pendingFirstPromptRetry = undefined;
1868
+ // Only a turn that was actually admitted (dispatched to the host) settles the
1869
+ // session's first prompt. A preflight rejection — validation, auth/preflight, a
1870
+ // stale-state conflict, an oversize frame, or an ensureProviders() failure — never
1871
+ // owned a turn, so it must not consume the one-shot first-turn retry budget: a later
1872
+ // valid first prompt that hits the startup readiness race must still be retryable
1873
+ // (review P2).
1874
+ if (retryReservation.admitted) record.firstPromptDone = true;
1875
+ }
1876
+ }
1877
+
1878
+ /**
1879
+ * The readiness-race signature (issue #5574): the session's first turn was accepted
1880
+ * and started producing frames (an `agent_start`), then failed as `prompt_failed` before
1881
+ * ever completing. Re-submitting the same first prompt a bounded number of times lets a
1882
+ * host that has since finished coming up answer it, instead of ending the session on its
1883
+ * first turn.
1884
+ *
1885
+ * A retry re-submits the prompt as a fresh, INDEPENDENT `turn.prompt` — `AcpSdkAdapter`
1886
+ * assigns a new request id per call — so it must never re-run a turn that already did
1887
+ * side-effecting work. If the failed turn executed a tool, re-submitting would run the
1888
+ * user's instruction, and its side effects, a second time (review P1). A turn that only
1889
+ * started (agent_start / streamed text) but ran no tool produced no external effect, so
1890
+ * recovering it is safe.
1891
+ *
1892
+ * Every gate is deliberately narrow so a genuine failure is still surfaced:
1893
+ * - only the first logical prompt of the session (`!firstPromptDone`);
1894
+ * - only the startup-readiness failure class the terminal itself classified
1895
+ * (`isStartupReadinessFailure`): a post-start `agent_failed` `prompt_failed` with a
1896
+ * provider/transport classifier — never a client cancel, a `prompt_deadline_exceeded`,
1897
+ * a provider rejection, an agent runtime failure, a transport error, or a preflight
1898
+ * rejection, however little output they happened to produce;
1899
+ * - only after the turn was observed starting (`promptObservedActivity`), the race's
1900
+ * fingerprint, which a turn rejected before it ever started never has;
1901
+ * - but NEVER once the turn executed a tool (`promptObservedToolExecution`), so a
1902
+ * progressed, side-effecting turn is never re-run as a second turn;
1903
+ * - but NEVER once the turn published assistant text/thought output
1904
+ * (`promptObservedAssistantOutput`), so a re-submit cannot expose duplicated output
1905
+ * from both attempts to the live ACP update stream — including a failed terminal's own
1906
+ * `finalText`, which is published on an async tail after this gate runs;
1907
+ * - never once the client has asked to cancel.
1908
+ */
1909
+ #shouldRetryFirstPrompt(record: SessionRecord, error: unknown, attempt: number): boolean {
1910
+ if (attempt >= ACP_FIRST_PROMPT_MAX_RETRIES) return false;
1911
+ // The primary gate: only the failure class the terminal classified as a startup
1912
+ // readiness race is recoverable. The activity/tool/output gates below stay as secondary
1913
+ // safety checks on top of it — they bound what may be re-run, not what is worth re-running.
1914
+ if (!isStartupReadinessFailure(error)) return false;
1915
+ if (record.firstPromptDone) return false;
1916
+ if (record.cancelRequested) return false;
1917
+ if (!record.promptObservedActivity) return false;
1918
+ // Never re-run a turn that already executed a tool: a re-submit is a new, independent
1919
+ // turn.prompt, so repeating a tool-executing turn would run the user's instruction and
1920
+ // its side effects a second time (review P1).
1921
+ if (record.promptObservedToolExecution) return false;
1922
+ // Never re-run a turn that already published — or has committed to publishing —
1923
+ // assistant text/thought output: a re-submit is a new, independent turn.prompt whose
1924
+ // output would be delivered to ACP consumers on top of the failed attempt's chunks or
1925
+ // its terminal's final text, duplicating or corrupting the assistant stream.
1926
+ if (record.promptObservedAssistantOutput) return false;
1927
+ return true;
1928
+ }
1929
+
1930
+ /**
1931
+ * Settles the publication tails a finished turn left running: its terminal's final text, its
1932
+ * end-of-turn metadata, and its failure diagnostic. Each tail owns one of `#submitPrompt`'s
1933
+ * conflict guards, and a tail can start another (the failure diagnostic awaits the final text),
1934
+ * so this drains until none is left rather than sampling once.
1935
+ */
1936
+ async #drainPromptPublicationTails(id: string): Promise<void> {
1937
+ for (;;) {
1938
+ const tails = [
1939
+ this.#finalTextTails.get(id),
1940
+ this.#terminalMetadataTails.get(id),
1941
+ this.#failureDiagnosticTails.get(id),
1942
+ ].filter((tail): tail is Promise<void> => tail !== undefined);
1943
+ if (tails.length === 0) return;
1944
+ // A publication failure is the tail's own business; it is logged where it happens and
1945
+ // must not decide the retry.
1946
+ await Promise.allSettled(tails);
1947
+ }
1948
+ }
1949
+
1950
+ /** Backoff between first-prompt retries, on the injectable watchdog clock so tests can advance it. */
1951
+ #delayFirstPromptRetry(attempt: number): Promise<void> {
1952
+ const delayMs = ACP_FIRST_PROMPT_RETRY_BASE_DELAY_MS * (attempt + 1);
1953
+ return new Promise<void>(resolve => {
1954
+ this.#promptWatchdogClock.schedule(resolve, delayMs);
1955
+ });
1956
+ }
1957
+
1958
+ async #submitPrompt(
1959
+ params: PromptRequest,
1960
+ echoUserMessage: boolean,
1961
+ retryReservation?: FirstPromptRetryReservation,
1962
+ ): Promise<PromptResponse> {
1617
1963
  const record = this.#sessions.get(params.sessionId);
1618
1964
  if (!record) throw new AcpSdkAdapterError("not_found", `Unknown session, not found: ${params.sessionId}`);
1965
+ // A first-turn retry holds the session across its backoff gap. Any prompt that is not the
1966
+ // retry owner is rejected here, before any state mutation or dispatch, so it cannot steal
1967
+ // the first turn while the authorized retry is still pending (review P1, issue #5574).
1968
+ if (record.pendingFirstPromptRetry && record.pendingFirstPromptRetry !== retryReservation)
1969
+ throw new AcpSdkAdapterError("conflict", "ACP session is retrying its first prompt.");
1619
1970
  if (record.activePrompt) throw new AcpSdkAdapterError("conflict", "ACP session already has an active prompt.");
1620
1971
  if (record.authFailure) throw new AcpSdkAdapterError("authentication_failed", record.authFailure);
1621
1972
  if (this.#retiredPromptAcknowledgements.has(params.sessionId))
@@ -1684,6 +2035,11 @@ export class AcpAgent implements Agent {
1684
2035
  )} KiB transport limit. Attach a smaller or more compressed image.`,
1685
2036
  );
1686
2037
  record.publicationGeneration++;
2038
+ // Reset per attempt: a first-turn readiness retry keys on whether THIS attempt was
2039
+ // observed doing work, never a prior attempt's activity.
2040
+ record.promptObservedActivity = false;
2041
+ record.promptObservedToolExecution = false;
2042
+ record.promptObservedAssistantOutput = false;
1687
2043
  const { promise: response, resolve, reject } = Promise.withResolvers<PromptResponse>();
1688
2044
  const waiter: PromptWaiter = {
1689
2045
  acknowledged: false,
@@ -1700,6 +2056,8 @@ export class AcpAgent implements Agent {
1700
2056
  lastFrameAt: this.#promptWatchdogClock.now(),
1701
2057
  lastFrameType: "prompt_dispatch",
1702
2058
  activity: new PromptActivity(),
2059
+ observedTurnActivity: false,
2060
+ observedToolExecution: false,
1703
2061
  resolve,
1704
2062
  reject,
1705
2063
  };
@@ -1746,19 +2104,26 @@ export class AcpAgent implements Agent {
1746
2104
  // image never appear in the client UI — only the agent's reply does. Replay
1747
2105
  // (session/load) already emits these; a live turn must too, and the image blocks
1748
2106
  // must be published verbatim so attachments are visible, not just fed to the model.
1749
- for (const block of params.prompt) {
1750
- if (block.type !== "text" && block.type !== "image") continue;
1751
- if (block.type === "text" && block.text.length === 0) continue;
1752
- await this.#publishSessionUpdate(
1753
- params.sessionId,
1754
- {
1755
- sessionId: params.sessionId,
1756
- update: { sessionUpdate: "user_message_chunk", content: block },
1757
- },
1758
- record.adapter,
1759
- );
1760
- }
2107
+ // A first-turn readiness retry (issue #5574) skips the echo: the message was already
2108
+ // published on the first attempt and re-echoing would duplicate it in the transcript.
2109
+ if (echoUserMessage)
2110
+ for (const block of params.prompt) {
2111
+ if (block.type !== "text" && block.type !== "image") continue;
2112
+ if (block.type === "text" && block.text.length === 0) continue;
2113
+ await this.#publishSessionUpdate(
2114
+ params.sessionId,
2115
+ {
2116
+ sessionId: params.sessionId,
2117
+ update: { sessionUpdate: "user_message_chunk", content: block },
2118
+ },
2119
+ record.adapter,
2120
+ );
2121
+ }
1761
2122
  waiter.dispatched = true;
2123
+ // The turn is now dispatched to the host: this prompt owned the session's first turn,
2124
+ // so it — not a preflight rejection that threw before this point — is what settles
2125
+ // `firstPromptDone` in `prompt()` (review P2).
2126
+ if (retryReservation) retryReservation.admitted = true;
1762
2127
  if (waiter.settled || record.activePrompt !== waiter) {
1763
2128
  return await response;
1764
2129
  }
@@ -1843,6 +2208,8 @@ export class AcpAgent implements Agent {
1843
2208
  }
1844
2209
  if (matchesWaiter) {
1845
2210
  this.#observePromptActivity(waiter, deferredFrame);
2211
+ if (waiter.observedTurnActivity) record.promptObservedActivity = true;
2212
+ if (waiter.observedToolExecution) record.promptObservedToolExecution = true;
1846
2213
  observedDeferredActivity = true;
1847
2214
  }
1848
2215
  }
@@ -2029,7 +2396,11 @@ export class AcpAgent implements Agent {
2029
2396
  }
2030
2397
  waiter?.cancelAttemptResolve?.(true);
2031
2398
  } catch (error) {
2032
- if (!waiter) record.cancelRequested = false;
2399
+ // With no active prompt the cancel intent normally clears — except when a first-turn
2400
+ // retry is reserved across its backoff gap. That reservation owner has not yet observed
2401
+ // the cancel; clearing it here would let the retry resubmit a turn the client cancelled
2402
+ // (review P1). Leave the flag for the retry's post-backoff check to settle as cancelled.
2403
+ if (!waiter && !record.pendingFirstPromptRetry) record.cancelRequested = false;
2033
2404
  // Only the LAST in-flight attempt resolves the shared promise false;
2034
2405
  // an earlier attempt may still acknowledge (review thread P2). After
2035
2406
  // every attempt of this wave failed, RE-ARM the aggregate: a later
@@ -2227,7 +2598,7 @@ export class AcpAgent implements Agent {
2227
2598
  // session host remains authoritative for its immutable configuration, so
2228
2599
  // attachment must not reinterpret the replay as a mutation request.
2229
2600
  this.#pendingDeleteLocators.delete(id);
2230
- await attached.adapter.ensureProviders();
2601
+ if (attached.primaryControlSurface === "sdk") await attached.adapter.ensureProviders();
2231
2602
  return;
2232
2603
  }
2233
2604
  const knownCwd = this.#knownSessionCwds.get(id);
@@ -2313,7 +2684,7 @@ export class AcpAgent implements Agent {
2313
2684
  if (existing) {
2314
2685
  if (path.resolve(existing.cwd) !== path.resolve(cwd))
2315
2686
  throw new AcpSdkAdapterError("conflict", `ACP session ${id} has conflicting cwd authority.`);
2316
- await existing.adapter.ensureProviders();
2687
+ if (existing.primaryControlSurface === "sdk") await existing.adapter.ensureProviders();
2317
2688
  return;
2318
2689
  }
2319
2690
  const attaching = this.#attaching.get(id);
@@ -2368,7 +2739,10 @@ export class AcpAgent implements Agent {
2368
2739
  });
2369
2740
  unsubscribePendingFrames = adapter.onFrame(frame => pendingAdapterFrames.push(frame));
2370
2741
  this.#pendingRouterAdapters.set(id, adapter);
2371
- await adapter.start();
2742
+ // Query startup provenance before activating reverse providers. A CLI
2743
+ // host must retain terminal permission/UI leases and lifecycle authority;
2744
+ // a broker-launched SDK host keeps ACP ownership across reconnects.
2745
+ await adapter.start({ activateProviders: false });
2372
2746
  let capabilities: JsonObject | undefined;
2373
2747
  try {
2374
2748
  const response = object(await adapter.query("runtime.capabilities"));
@@ -2377,11 +2751,22 @@ export class AcpAgent implements Agent {
2377
2751
  // page item, so fall back to the envelope only for direct-result hosts.
2378
2752
  capabilities = object(pageItems(result)[0]) ?? result;
2379
2753
  } catch {}
2380
- if (capabilities?.promptTerminalOutcomeVersion !== 1)
2754
+ const primaryControlSurface =
2755
+ capabilities?.primaryControlSurface === "sdk"
2756
+ ? "sdk"
2757
+ : capabilities?.primaryControlSurface === "cli"
2758
+ ? "cli"
2759
+ : undefined;
2760
+ if (capabilities?.promptTerminalOutcomeVersion !== 1 || primaryControlSurface === undefined)
2381
2761
  throw new AcpSdkAdapterError(
2382
2762
  "unavailable",
2383
- "This ACP client requires a newer GJC SDK session; restart the session.",
2763
+ "This ACP client requires a newer GJC SDK session with startup control provenance; restart the session.",
2384
2764
  );
2765
+ if (primaryControlSurface === "sdk") {
2766
+ adapter.authorizeProviderActivation();
2767
+ await adapter.ensureProviders();
2768
+ await applyAcpPermissionMode(adapter, this.#clientCapabilities);
2769
+ }
2385
2770
  this.#assertSessionEpoch(id, epoch);
2386
2771
  const exactAttachment = this.#router.attachment(id) ?? currentAttachment;
2387
2772
  if (!exactAttachment.isCurrent())
@@ -2389,6 +2774,7 @@ export class AcpAgent implements Agent {
2389
2774
  const record: SessionRecord = {
2390
2775
  cwd,
2391
2776
  adapter,
2777
+ primaryControlSurface,
2392
2778
  attachment: exactAttachment,
2393
2779
  closeIdempotencyKey: randomUUID(),
2394
2780
  unsubscribe: () => {},
@@ -2404,6 +2790,12 @@ export class AcpAgent implements Agent {
2404
2790
  backgroundCorrelations: [],
2405
2791
  toolArgs: new Map(),
2406
2792
  };
2793
+ // Preserve first-turn state across reattachment (issue #5574). The startup-readiness
2794
+ // retry only guards a session's very first logical prompt; a fresh record built on
2795
+ // reconnect/reattach would otherwise reset that guard and let a later prompt take the
2796
+ // retry — and re-run — path. Reattachment retains the session's settled prompt
2797
+ // correlations, so a non-empty set proves at least one prompt has already completed.
2798
+ if (record.settledPromptCorrelations.length > 0) record.firstPromptDone = true;
2407
2799
  record.unsubscribe = adapter.onFrame(frame => this.#enqueueSdkFrame(id, adapter!, frame));
2408
2800
  record.reconnectUnsubscribe = adapter.onReconnectFailed(error =>
2409
2801
  this.#recoverSessionAfterTransportFailure(id, adapter!, error),
@@ -2415,8 +2807,10 @@ export class AcpAgent implements Agent {
2415
2807
  this.#pendingRouterAdapters.delete(id);
2416
2808
  this.#pendingRouterFrames.delete(id);
2417
2809
  this.#knownSessionCwds.set(id, cwd);
2418
- this.#ownedSessionIds.add(id);
2419
- await applyAcpPermissionMode(adapter, this.#clientCapabilities);
2810
+ // Host provenance is authoritative for lifecycle authority: a CLI-primary host
2811
+ // keeps it in its terminal even when this connection created the session.
2812
+ if (primaryControlSurface === "sdk") this.#ownedSessionIds.add(id);
2813
+ else this.#ownedSessionIds.delete(id);
2420
2814
  this.#assertSessionEpoch(id, epoch);
2421
2815
  // A successful attachment establishes a new live-owner epoch. Any locator
2422
2816
  // retained from an earlier cleanup_pending delete belongs to the terminal
@@ -2552,7 +2946,9 @@ export class AcpAgent implements Agent {
2552
2946
  // The record is published before permission initialization. Let a canceled
2553
2947
  // provisional attachment retire it before selecting the generation key.
2554
2948
  if (attaching) await Promise.allSettled([attaching.task]);
2555
- await this.#teardownSession(id, "closed", true);
2949
+ // A CLI-primary host owns its own process lifecycle: release this ACP
2950
+ // attachment locally and never ask the broker to close the session.
2951
+ await this.#teardownSession(id, "closed", this.#ownedSessionIds.has(id));
2556
2952
  this.#knownSessionCwds.delete(id);
2557
2953
  this.#ownedSessionIds.delete(id);
2558
2954
  this.#knownSessionMcpServers.delete(id);
@@ -2586,19 +2982,36 @@ export class AcpAgent implements Agent {
2586
2982
  record.unsubscribe();
2587
2983
  record.reconnectUnsubscribe();
2588
2984
  record.activePrompt = undefined;
2589
- // `session/close` is the client asking to end its own work, so the pending turn
2590
- // settles as `cancelled` rather than surfacing a spurious error. ACP: "Agents
2591
- // MUST catch these errors and return the semantically meaningful `cancelled`
2592
- // stop reason." Involuntary teardown (transport loss) still rejects.
2985
+ const voluntary = reason === "closed" || reason === "discarded" || reason === "deleted";
2986
+ // `session/close` and `session/delete` are the client asking to end its own work, so
2987
+ // the pending turn settles as `cancelled` rather than surfacing a spurious error.
2988
+ // ACP: "Agents MUST catch these errors and return the semantically meaningful
2989
+ // `cancelled` stop reason." Involuntary teardown (transport loss) still rejects.
2593
2990
  if (waiter && !waiter.settled) {
2594
2991
  clearPromptWatchdog(waiter);
2595
- if (reason === "closed" || reason === "discarded") {
2992
+ if (voluntary) {
2596
2993
  waiter.settled = true;
2597
2994
  waiter.resolve({ stopReason: "cancelled" });
2598
2995
  } else {
2599
2996
  waiter.reject(new AcpSdkAdapterError("connection_closed", `ACP session was ${reason}.`));
2600
2997
  }
2601
2998
  }
2999
+ // A first-turn retry sleeping out its backoff owns no waiter — its failed attempt
3000
+ // already cleared `activePrompt` — so the settlement above cannot reach it. Record
3001
+ // the teardown on its reservation instead, with the same voluntary/involuntary
3002
+ // split, and release the reservation: otherwise the retry wakes to a deleted
3003
+ // record and resubmits, surfacing `not_found` where the client's close/delete owes
3004
+ // `cancelled` (review P1).
3005
+ const reservation = record.pendingFirstPromptRetry;
3006
+ if (reservation) {
3007
+ reservation.settlement ??= voluntary
3008
+ ? { kind: "cancelled" }
3009
+ : {
3010
+ kind: "rejected",
3011
+ error: new AcpSdkAdapterError("connection_closed", `ACP session was ${reason}.`),
3012
+ };
3013
+ record.pendingFirstPromptRetry = undefined;
3014
+ }
2602
3015
  }
2603
3016
 
2604
3017
  const failures: unknown[] = [];
@@ -2809,6 +3222,8 @@ export class AcpAgent implements Agent {
2809
3222
  }
2810
3223
  if (!correlationsExactlyMatch(waiter.correlation, correlation)) return;
2811
3224
  this.#observePromptActivity(waiter, frame);
3225
+ if (waiter.observedTurnActivity) record.promptObservedActivity = true;
3226
+ if (waiter.observedToolExecution) record.promptObservedToolExecution = true;
2812
3227
  this.#armPromptWatchdog(id, record, waiter);
2813
3228
  }
2814
3229
 
@@ -2817,6 +3232,10 @@ export class AcpAgent implements Agent {
2817
3232
  waiter.lastFrameAt = this.#promptWatchdogClock.now();
2818
3233
  waiter.lastFrameType =
2819
3234
  typeof event?.type === "string" ? event.type : typeof frame.type === "string" ? frame.type : "unknown";
3235
+ if (isTurnProgressEventType(typeof event?.type === "string" ? event.type : undefined))
3236
+ waiter.observedTurnActivity = true;
3237
+ if (isTurnToolExecutionEventType(typeof event?.type === "string" ? event.type : undefined))
3238
+ waiter.observedToolExecution = true;
2820
3239
  waiter.activity.observe(
2821
3240
  typeof event?.type === "string" ? event.type : undefined,
2822
3241
  typeof event?.toolCallId === "string" ? event.toolCallId : undefined,
@@ -3031,7 +3450,24 @@ export class AcpAgent implements Agent {
3031
3450
  );
3032
3451
  return;
3033
3452
  }
3034
- activePrompt.terminal = { outcome, correlation };
3453
+ // When a client cancel has been requested, a trailing stopped terminal may
3454
+ // still carry the normal `end_turn` reason (the model finished its response as
3455
+ // the cancel arrived mid-stream, or the cancel was processed before the prompt
3456
+ // was acknowledged). The client's cancellation is the authoritative cause;
3457
+ // remap only `end_turn` — deliberate host reasons (e.g. `refusal`, `max_tokens`)
3458
+ // remain authoritative and must NOT be overridden.
3459
+ const settledOutcome =
3460
+ record.cancelRequested && outcome.kind === "stopped" && outcome.reason === "end_turn"
3461
+ ? { ...outcome, reason: "cancelled" as const, provenance: "client_cancel" as const }
3462
+ : outcome;
3463
+ activePrompt.terminal = { outcome: settledOutcome, correlation };
3464
+ // A terminal carrying final text still publishes it as an assistant chunk, on the
3465
+ // async tail `#scheduleTerminalUpdates` starts below. A failed terminal can carry it
3466
+ // with no preceding stream chunks at all, so the streamed-chunk flag stays false and
3467
+ // the first-turn retry gate — which the settlement below releases — would see
3468
+ // "started, no output", resubmit, and let BOTH this terminal's final text and the
3469
+ // retry's answer reach ACP consumers. Record the output before settling (review P1).
3470
+ if (typeof event.finalText === "string" && event.finalText) record.promptObservedAssistantOutput = true;
3035
3471
  // Failure diagnostics are useful but advisory. Settle before any mapped
3036
3472
  // session update can await a backpressured client transport; otherwise an
3037
3473
  // already-decided failure can still lose to the inactivity watchdog.
@@ -3098,6 +3534,16 @@ export class AcpAgent implements Agent {
3098
3534
  notification.update.content.type === "text"
3099
3535
  )
3100
3536
  promptOwner.emittedAssistantText += notification.update.content.text;
3537
+ // A live assistant text/thought chunk for this prompt owner has now been exposed to
3538
+ // ACP consumers. Record it so the first-turn readiness retry is vetoed: a re-submit
3539
+ // would deliver a second attempt's output on top of these chunks (issue #5574).
3540
+ if (
3541
+ promptOwner &&
3542
+ event.type !== "agent_failed" &&
3543
+ (notification.update.sessionUpdate === "agent_message_chunk" ||
3544
+ notification.update.sessionUpdate === "agent_thought_chunk")
3545
+ )
3546
+ record.promptObservedAssistantOutput = true;
3101
3547
  // The prompt rejection carries the sanitized failure diagnostic. Publishing
3102
3548
  // a second session update after settlement would be stale as soon as the client
3103
3549
  // starts a replacement turn, and an in-flight transport write cannot be revoked.
@@ -3344,7 +3790,16 @@ export class AcpAgent implements Agent {
3344
3790
  waiter.resolve({ stopReason: outcome.reason });
3345
3791
  return;
3346
3792
  }
3347
- waiter.reject(new AcpSdkAdapterError(outcome.code, outcome.message));
3793
+ // `phase` is the host's own claim, and a host that omits it leaves `terminalOutcome`
3794
+ // deriving the phase with no evidence at all — `submission`, even for a turn this
3795
+ // connection watched start. Upgrade it from the frames this prompt actually owned, never
3796
+ // downgrade a terminal that already reported `post_start`, and carry the whole
3797
+ // classification on the rejection so the first-turn retry gate can read it (review P1).
3798
+ const failure =
3799
+ outcome.phase === "post_start"
3800
+ ? outcome
3801
+ : (rephaseFailedOutcome(outcome, { hasActivity: waiter.observedTurnActivity }) as SdkPromptFailedOutcome);
3802
+ waiter.reject(new AcpPromptFailureError(failure));
3348
3803
  }
3349
3804
 
3350
3805
  async #emitEndOfTurnUpdates(id: string, adapter: AcpSdkAdapter, publicationGeneration: number): Promise<void> {
@@ -3473,25 +3928,20 @@ export class AcpAgent implements Agent {
3473
3928
  ): Promise<Pick<NewSessionResponse, "configOptions" | "modes">> {
3474
3929
  const record = this.#sessions.get(id);
3475
3930
  if (!record) throw new AcpSdkAdapterError("not_found", `Unknown session, not found: ${id}`);
3476
- const modelPreset = this.#startupOptions?.modelPreset;
3477
- const [config, sessionCatalog] = await Promise.all([
3478
- record.adapter.query("config.list/get"),
3479
- modelPreset === undefined
3480
- ? collectModelCatalogAndActiveProviders(record.adapter)
3481
- : record.adapter
3482
- .query("models.profiles.list")
3483
- .then((profiles): { modelCatalog: unknown; activeProviders: undefined } => ({
3484
- modelCatalog: profiles,
3485
- activeProviders: undefined,
3486
- })),
3487
- ]);
3488
- // The active-provider walk overlaps the catalog inside the batch above, but
3489
- // only after the first models.list/current page has finalized host-side
3490
- // credential state, so catalog rows and provider availability never mix
3491
- // pre- and post-refresh credential snapshots. Only an older session host
3492
- // that rejects `providers.list/active` with `operation_not_session_owned`
3493
- // falls back to the full catalog; operational failures fail closed so the
3494
- // active-provider contract is not silently widened.
3931
+ const config = await record.adapter.query("config.list/get");
3932
+ const modelPreset = this.#startupOptions?.modelPreset ?? activeModelPresetFromConfig(config);
3933
+ const sessionCatalog = await (modelPreset === undefined
3934
+ ? collectModelCatalogAndActiveProviders(record.adapter)
3935
+ : record.adapter
3936
+ .query("models.profiles.list")
3937
+ .then((profiles): { modelCatalog: unknown; activeProviders: undefined } => ({
3938
+ modelCatalog: profiles,
3939
+ activeProviders: undefined,
3940
+ })));
3941
+ // Only an older session host that rejects `providers.list/active` with
3942
+ // `operation_not_session_owned` falls back to the full catalog;
3943
+ // operational failures fail closed so the active-provider contract is not
3944
+ // silently widened.
3495
3945
  const modelCatalog = sessionCatalog.modelCatalog;
3496
3946
  const activeProviders = sessionCatalog.activeProviders;
3497
3947
  record.authFailure = undefined;
@@ -3787,6 +4237,10 @@ export class AcpAgent implements Agent {
3787
4237
  );
3788
4238
  }
3789
4239
  const messageId = typeof message.id === "string" ? message.id : undefined;
4240
+ // A replayed user turn is proof the loaded session already had a prompt, so the
4241
+ // next live prompt is not its first — keep the first-turn readiness retry (issue
4242
+ // #5574) off for a resumed/loaded session that has prior history.
4243
+ if (message.role === "user") record.firstPromptDone = true;
3790
4244
  const richContent = Array.isArray(message.content) ? message.content : undefined;
3791
4245
  if ((message.role === "user" || message.role === "assistant") && richContent) {
3792
4246
  for (const rawBlock of richContent) {