@gajae-code/coding-agent 0.15.5 → 0.16.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 (272) hide show
  1. package/CHANGELOG.md +85 -1
  2. package/dist/types/async/job-manager.d.ts +83 -0
  3. package/dist/types/cli/args.d.ts +2 -0
  4. package/dist/types/cli/auth-gateway-cli.d.ts +17 -0
  5. package/dist/types/cli/model-presets-cli.d.ts +9 -0
  6. package/dist/types/cli/root-flags.d.ts +14 -0
  7. package/dist/types/cli/update-cli.d.ts +15 -0
  8. package/dist/types/cli.d.ts +7 -0
  9. package/dist/types/commands/auth-gateway.d.ts +3 -0
  10. package/dist/types/commands/gc.d.ts +13 -0
  11. package/dist/types/commands/launch.d.ts +10 -0
  12. package/dist/types/commands/model-presets.d.ts +21 -0
  13. package/dist/types/config/autorouting-tier-map.d.ts +120 -0
  14. package/dist/types/config/file-lock.d.ts +54 -2
  15. package/dist/types/config/internal/model-preset-registry-test-state.d.ts +4 -0
  16. package/dist/types/config/internal/model-preset-registry-test-support.d.ts +3 -0
  17. package/dist/types/config/keybindings.d.ts +3 -2
  18. package/dist/types/config/model-preset-registry.d.ts +368 -0
  19. package/dist/types/config/model-profile-activation.d.ts +39 -0
  20. package/dist/types/config/model-profiles.d.ts +5 -3
  21. package/dist/types/config/model-registry.d.ts +17 -5
  22. package/dist/types/config/settings-schema.d.ts +19 -1
  23. package/dist/types/config/settings.d.ts +2 -0
  24. package/dist/types/coordinator-mcp/model-preset.d.ts +7 -5
  25. package/dist/types/coordinator-mcp/projection-scan.d.ts +54 -0
  26. package/dist/types/crash/record-loader.d.ts +15 -0
  27. package/dist/types/extensibility/extensions/types.d.ts +2 -5
  28. package/dist/types/gjc-runtime/empty-delete-gc.d.ts +58 -0
  29. package/dist/types/gjc-runtime/gc-runtime.d.ts +2 -0
  30. package/dist/types/gjc-runtime/launch-worktree-reservation.d.ts +21 -0
  31. package/dist/types/gjc-runtime/managed-owner-supervisor.d.ts +2 -0
  32. package/dist/types/gjc-runtime/session-state-lock.d.ts +35 -6
  33. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +72 -1
  34. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +1 -0
  35. package/dist/types/gjc-runtime/tmux-sessions.d.ts +27 -0
  36. package/dist/types/index.d.ts +1 -0
  37. package/dist/types/main.d.ts +3 -1
  38. package/dist/types/master-mode/context.d.ts +21 -0
  39. package/dist/types/master-mode/first-request.d.ts +34 -0
  40. package/dist/types/master-mode/snapshot.d.ts +32 -0
  41. package/dist/types/modes/components/frictionless-onboarding-selector.d.ts +10 -0
  42. package/dist/types/modes/components/jobs-overlay-model.d.ts +4 -2
  43. package/dist/types/modes/components/oauth-selector.d.ts +1 -0
  44. package/dist/types/modes/components/status-line/model-name.d.ts +18 -0
  45. package/dist/types/modes/components/status-line/priority-row.d.ts +34 -0
  46. package/dist/types/modes/components/status-line/segments.d.ts +10 -0
  47. package/dist/types/modes/jobs-observer.d.ts +36 -0
  48. package/dist/types/modes/print-mode.d.ts +17 -0
  49. package/dist/types/modes/ui-language.d.ts +7 -1
  50. package/dist/types/sdk/broker/broker.d.ts +80 -1
  51. package/dist/types/sdk/broker/endpoint-authority.d.ts +24 -0
  52. package/dist/types/sdk/broker/index.d.ts +3 -0
  53. package/dist/types/sdk/broker/lifecycle.d.ts +26 -0
  54. package/dist/types/sdk/broker/master-capability.d.ts +10 -0
  55. package/dist/types/sdk/broker/session-index.d.ts +40 -11
  56. package/dist/types/sdk/broker/session-scope.d.ts +72 -0
  57. package/dist/types/sdk/broker/spawn-authority.d.ts +201 -0
  58. package/dist/types/sdk/broker/spawn-substrate.d.ts +30 -0
  59. package/dist/types/sdk/broker/state-version.d.ts +4 -1
  60. package/dist/types/sdk/broker/worktree-occupancy.d.ts +28 -0
  61. package/dist/types/sdk/bus/chat-daemon-control.d.ts +3 -0
  62. package/dist/types/sdk/bus/index.d.ts +6 -0
  63. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +3 -0
  64. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +4 -0
  65. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  66. package/dist/types/sdk/cli/index.d.ts +1 -0
  67. package/dist/types/sdk/cli/master-cli.d.ts +63 -0
  68. package/dist/types/sdk/cli/rows.d.ts +4 -3
  69. package/dist/types/sdk/cli/session-cli.d.ts +18 -2
  70. package/dist/types/sdk/client/client.d.ts +3 -2
  71. package/dist/types/sdk/client/discovery.d.ts +1 -0
  72. package/dist/types/sdk/host/control/dispatch.d.ts +10 -0
  73. package/dist/types/sdk/host/control/index.d.ts +1 -1
  74. package/dist/types/sdk/host/control/operations.d.ts +2 -0
  75. package/dist/types/sdk/host/control/runtime-gate.d.ts +21 -7
  76. package/dist/types/sdk/host/host.d.ts +10 -0
  77. package/dist/types/sdk/host/model-pin.d.ts +7 -2
  78. package/dist/types/sdk/host/sdk-run-capability.d.ts +1 -0
  79. package/dist/types/sdk/host/session-runtime.d.ts +67 -1
  80. package/dist/types/sdk/lifecycle/broker-client.d.ts +6 -0
  81. package/dist/types/sdk/lifecycle/service.d.ts +15 -3
  82. package/dist/types/sdk/lifecycle-session.d.ts +2 -0
  83. package/dist/types/sdk/prompt-deadline-manager.d.ts +11 -1
  84. package/dist/types/sdk/protocol/adapter-validation.d.ts +2 -0
  85. package/dist/types/sdk/router/session-router.d.ts +11 -0
  86. package/dist/types/sdk/session.d.ts +16 -0
  87. package/dist/types/sdk/startup-capability.d.ts +2 -1
  88. package/dist/types/session/account-inventory.d.ts +1 -0
  89. package/dist/types/session/agent-session.d.ts +24 -7
  90. package/dist/types/session/credential-store-errors.d.ts +1 -0
  91. package/dist/types/session/fold-coordinator.d.ts +201 -0
  92. package/dist/types/session/sdk-run-capability-internal.d.ts +7 -0
  93. package/dist/types/session/sdk-run-capability.d.ts +2 -0
  94. package/dist/types/session/session-manager.d.ts +65 -0
  95. package/dist/types/session/yield-queue.d.ts +5 -4
  96. package/dist/types/setup/frictionless-onboarding.d.ts +1 -0
  97. package/dist/types/telemetry/control.d.ts +11 -0
  98. package/dist/types/telemetry/events.d.ts +20 -0
  99. package/dist/types/telemetry/index.d.ts +3 -0
  100. package/dist/types/telemetry/transport.d.ts +22 -0
  101. package/dist/types/tools/bash-interactive.d.ts +23 -0
  102. package/dist/types/tools/bash.d.ts +4 -0
  103. package/dist/types/tools/gh.d.ts +8 -0
  104. package/dist/types/tools/index.d.ts +10 -5
  105. package/dist/types/web/search/providers/base.d.ts +9 -4
  106. package/dist/types/web/search/providers/xai.d.ts +2 -1
  107. package/dist/types/web/search/types.d.ts +14 -0
  108. package/package.json +10 -7
  109. package/scripts/generate-hotkeys-docs.ts +4 -0
  110. package/scripts/generate-sdk-operation-inventory.ts +9 -3
  111. package/scripts/verify-gjc-sdk-canonicalization.ts +35 -1
  112. package/src/async/job-manager.ts +671 -39
  113. package/src/cli/accounts-cli.ts +9 -5
  114. package/src/cli/args.ts +17 -0
  115. package/src/cli/auth-broker-cli.ts +40 -12
  116. package/src/cli/auth-gateway-cli.ts +273 -97
  117. package/src/cli/config-cli.ts +2 -2
  118. package/src/cli/fast-help.ts +4 -0
  119. package/src/cli/model-presets-cli.ts +100 -0
  120. package/src/cli/root-flags.ts +5 -0
  121. package/src/cli/update-cli.ts +117 -16
  122. package/src/cli.ts +6 -2
  123. package/src/commands/auth-gateway.ts +12 -8
  124. package/src/commands/gc.ts +19 -0
  125. package/src/commands/launch.ts +86 -18
  126. package/src/commands/model-presets.ts +24 -0
  127. package/src/commands/sdk.ts +113 -21
  128. package/src/config/autorouting-tier-map.ts +44 -0
  129. package/src/config/file-lock-gc.ts +9 -4
  130. package/src/config/file-lock.ts +890 -99
  131. package/src/config/internal/model-preset-registry-test-state.ts +28 -0
  132. package/src/config/internal/model-preset-registry-test-support.ts +11 -0
  133. package/src/config/keybindings.ts +6 -2
  134. package/src/config/model-preset-registry.ts +2795 -0
  135. package/src/config/model-profile-activation.ts +115 -28
  136. package/src/config/model-profile-contract.ts +4 -1
  137. package/src/config/model-profiles.ts +56 -14
  138. package/src/config/model-registry.ts +1044 -122
  139. package/src/config/model-resolver.ts +5 -0
  140. package/src/config/settings-schema.ts +19 -1
  141. package/src/config/settings.ts +11 -1
  142. package/src/coordinator-mcp/model-preset.ts +32 -17
  143. package/src/coordinator-mcp/projection-scan.ts +592 -0
  144. package/src/coordinator-mcp/question-state.ts +9 -11
  145. package/src/coordinator-mcp/server.ts +111 -42
  146. package/src/coordinator-mcp/session-reaper.ts +9 -3
  147. package/src/crash/index-store.ts +2 -0
  148. package/src/crash/record-loader.ts +10 -0
  149. package/src/extensibility/extensions/loader.ts +0 -1
  150. package/src/extensibility/extensions/types.ts +2 -3
  151. package/src/extensibility/gjc-plugins/runtime-adapters.ts +1 -0
  152. package/src/gjc-runtime/empty-delete-gc.ts +301 -0
  153. package/src/gjc-runtime/gc-render.ts +29 -0
  154. package/src/gjc-runtime/gc-runtime.ts +174 -11
  155. package/src/gjc-runtime/launch-worktree-reservation.ts +242 -0
  156. package/src/gjc-runtime/managed-owner-supervisor.ts +63 -51
  157. package/src/gjc-runtime/session-state-lock.ts +1321 -147
  158. package/src/gjc-runtime/session-state-sidecar.ts +1004 -25
  159. package/src/gjc-runtime/tmux-owner-isolation-cli.ts +4 -7
  160. package/src/gjc-runtime/tmux-owner-isolation.ts +2 -2
  161. package/src/gjc-runtime/tmux-sessions.ts +252 -13
  162. package/src/hooks/skill-state.ts +6 -0
  163. package/src/index.ts +1 -0
  164. package/src/internal-urls/docs-index.generated.ts +14 -13
  165. package/src/internal-urls/issue-pr-protocol.ts +4 -2
  166. package/src/lsp/config.ts +4 -3
  167. package/src/lsp/path-trust.ts +12 -6
  168. package/src/main.ts +66 -4
  169. package/src/master-mode/context.ts +50 -0
  170. package/src/master-mode/first-request.ts +65 -0
  171. package/src/master-mode/snapshot.ts +205 -0
  172. package/src/modes/acp/acp-agent.ts +616 -161
  173. package/src/modes/components/footer.ts +4 -2
  174. package/src/modes/components/frictionless-onboarding-selector.ts +48 -2
  175. package/src/modes/components/jobs-overlay-model.ts +99 -16
  176. package/src/modes/components/model-selector.ts +158 -18
  177. package/src/modes/components/oauth-selector.ts +6 -1
  178. package/src/modes/components/settings-selector.ts +1 -0
  179. package/src/modes/components/status-line/model-name.ts +53 -0
  180. package/src/modes/components/status-line/priority-row.ts +107 -0
  181. package/src/modes/components/status-line/segments.ts +29 -10
  182. package/src/modes/components/tool-status-header.ts +151 -15
  183. package/src/modes/controllers/extension-ui-controller.ts +1 -1
  184. package/src/modes/controllers/input-controller.ts +51 -6
  185. package/src/modes/controllers/selector-controller.ts +191 -96
  186. package/src/modes/interactive-mode.ts +14 -1
  187. package/src/modes/jobs-observer.ts +149 -16
  188. package/src/modes/print-mode.ts +64 -0
  189. package/src/modes/runtime-init.ts +1 -1
  190. package/src/modes/ui-language.ts +49 -2
  191. package/src/modes/utils/injected-user-submission.ts +15 -1
  192. package/src/prompts/system/master-mode.md +28 -0
  193. package/src/prompts/tools/bash.md +2 -2
  194. package/src/prompts/tools/fold-receipt.md +3 -0
  195. package/src/prompts/tools/job.md +1 -1
  196. package/src/sdk/acp/adapter.ts +17 -2
  197. package/src/sdk/broker/broker.ts +1671 -34
  198. package/src/sdk/broker/endpoint-authority.ts +86 -0
  199. package/src/sdk/broker/ensure.ts +5 -0
  200. package/src/sdk/broker/index.ts +3 -0
  201. package/src/sdk/broker/lifecycle.ts +217 -46
  202. package/src/sdk/broker/master-capability.ts +232 -0
  203. package/src/sdk/broker/session-index.ts +121 -17
  204. package/src/sdk/broker/session-scope.ts +272 -0
  205. package/src/sdk/broker/spawn-authority.ts +1031 -0
  206. package/src/sdk/broker/spawn-substrate.ts +548 -0
  207. package/src/sdk/broker/state-version.ts +26 -14
  208. package/src/sdk/broker/transport.ts +1 -0
  209. package/src/sdk/broker/worktree-occupancy.ts +90 -0
  210. package/src/sdk/bus/chat-daemon-control.ts +5 -2
  211. package/src/sdk/bus/chat-daemon-runtime.ts +46 -5
  212. package/src/sdk/bus/index.ts +51 -4
  213. package/src/sdk/bus/kind-aware-reconciliation.ts +36 -6
  214. package/src/sdk/bus/prompt-reconciliation.ts +28 -4
  215. package/src/sdk/bus/slack-conversation.ts +5 -1
  216. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  217. package/src/sdk/cli/index.ts +1 -0
  218. package/src/sdk/cli/master-cli.ts +259 -0
  219. package/src/sdk/cli/rows.ts +11 -8
  220. package/src/sdk/cli/session-cli.ts +276 -6
  221. package/src/sdk/client/client.ts +14 -8
  222. package/src/sdk/client/discovery.ts +3 -1
  223. package/src/sdk/host/control/dispatch.ts +101 -37
  224. package/src/sdk/host/control/index.ts +2 -0
  225. package/src/sdk/host/control/operations.ts +2 -0
  226. package/src/sdk/host/control/runtime-gate.ts +203 -11
  227. package/src/sdk/host/host.ts +27 -2
  228. package/src/sdk/host/model-pin.ts +185 -25
  229. package/src/sdk/host/sdk-run-capability.ts +1 -0
  230. package/src/sdk/host/session-runtime.test.ts +581 -43
  231. package/src/sdk/host/session-runtime.ts +655 -101
  232. package/src/sdk/lifecycle/broker-client.ts +28 -0
  233. package/src/sdk/lifecycle/service.ts +279 -20
  234. package/src/sdk/lifecycle-session.ts +10 -2
  235. package/src/sdk/prompt-deadline-manager.ts +16 -1
  236. package/src/sdk/protocol/adapter-validation.ts +27 -0
  237. package/src/sdk/protocol/operation-inventory.generated.json +53 -2
  238. package/src/sdk/protocol/operation-registry.ts +17 -4
  239. package/src/sdk/router/session-router.ts +294 -154
  240. package/src/sdk/session.ts +440 -167
  241. package/src/sdk/startup-capability.ts +1 -0
  242. package/src/session/account-inventory.ts +27 -11
  243. package/src/session/agent-session.ts +1292 -180
  244. package/src/session/blob-store.ts +15 -5
  245. package/src/session/credential-store-errors.ts +2 -0
  246. package/src/session/fold-coordinator.ts +433 -0
  247. package/src/session/internal/managed-session-storage.ts +153 -78
  248. package/src/session/sdk-run-capability-internal.ts +15 -0
  249. package/src/session/sdk-run-capability.ts +2 -0
  250. package/src/session/session-manager.ts +342 -28
  251. package/src/session/yield-queue.ts +24 -10
  252. package/src/setup/frictionless-onboarding.ts +2 -0
  253. package/src/setup/provider-presets.json +1 -1
  254. package/src/slash-commands/helpers/credential-switch.ts +6 -1
  255. package/src/task/executor.ts +19 -3
  256. package/src/telemetry/control.ts +26 -0
  257. package/src/telemetry/events.ts +1268 -0
  258. package/src/telemetry/index.ts +3 -0
  259. package/src/telemetry/transport.ts +87 -0
  260. package/src/tools/bash-interactive.ts +182 -85
  261. package/src/tools/bash.ts +1028 -109
  262. package/src/tools/gh.ts +104 -48
  263. package/src/tools/github-cache.ts +2 -2
  264. package/src/tools/index.ts +11 -5
  265. package/src/tools/tool-catalog.generated.ts +1 -1
  266. package/src/web/search/provider.ts +120 -11
  267. package/src/web/search/providers/anthropic.ts +89 -17
  268. package/src/web/search/providers/base.ts +9 -4
  269. package/src/web/search/providers/gemini.ts +87 -15
  270. package/src/web/search/providers/openai-compatible.ts +23 -8
  271. package/src/web/search/providers/xai.ts +98 -12
  272. package/src/web/search/types.ts +16 -0
@@ -36,7 +36,7 @@ import {
36
36
  type SetSessionModeRequest,
37
37
  type SetSessionModeResponse,
38
38
  } from "@agentclientprotocol/sdk";
39
- import { getAgentDir, logger } from "@gajae-code/utils";
39
+ import { getAgentDir, logger, resolveEquivalentPath } from "@gajae-code/utils";
40
40
  import packageJson from "../../../package.json" with { type: "json" };
41
41
  import {
42
42
  ACP_SESSION_RECONNECT,
@@ -49,11 +49,13 @@ import {
49
49
  import { resolveAcpFinalText } from "../../sdk/acp/final-text";
50
50
  import { ACP_MCP_LIFECYCLE_TIMEOUT_MS, type SessionLifecycleMcpServer } from "../../sdk/acp/mcp";
51
51
  import { ensureBroker } from "../../sdk/broker/ensure";
52
+ import { canonicalSessionCwd } from "../../sdk/broker/session-index";
52
53
  import { readSdkBrokerDiscovery, SdkClient, SdkClientError } from "../../sdk/client";
53
54
  import type { AbortScope } from "../../sdk/host/control/operations";
54
55
  import { SYNTHETIC_PROVIDER_ID } from "../../sdk/model-profile-namespace";
55
56
  import type { SdkPromptTerminalOutcome } from "../../sdk/prompt-status";
56
57
  import { PromptActivity, type PromptWatchdogClock, systemPromptWatchdogClock } from "../../sdk/prompt-watchdog";
58
+ import { validateRequiredPromptText } from "../../sdk/protocol/adapter-validation";
57
59
  import { type SessionAttachment, SessionRouter, type SessionRouterFrame } from "../../sdk/router";
58
60
  import { SessionListTraversalError, sessionListPageFromResponse, traverseSessionList } from "../../sdk/session-list";
59
61
  import { resolveAcpAbortScope } from "./abort-scope";
@@ -105,6 +107,8 @@ interface PromptWaiter {
105
107
  acknowledged: boolean;
106
108
  /** True once turn.prompt / skill.invoke has been sent; cancel must not fake-settle after this. */
107
109
  dispatched: boolean;
110
+ /** True only while the dispatched control request can still reveal its correlation. */
111
+ acknowledgementPending: boolean;
108
112
 
109
113
  /** Highest inbound frame sequence already observed when the prompt was acknowledged. */
110
114
  boundary: number;
@@ -113,8 +117,12 @@ interface PromptWaiter {
113
117
  emittedAssistantText: string;
114
118
  settled: boolean;
115
119
  terminal?: { outcome: SdkPromptTerminalOutcome; correlation: PromptCorrelation };
120
+ /** Exact terminal observed at ingress; no later timeout/cancel/frame failure may supersede it. */
121
+ terminalReserved: boolean;
122
+ /** Additive diagnostics held until settlement so they cannot block the terminal response writer. */
123
+ failureDiagnostics: Array<{ notification: SessionNotification; publicationGeneration: number }>;
116
124
  /** Frames for an already-settled correlation held until acknowledgement resolves ownership. */
117
- deferredFrames: JsonObject[];
125
+ deferredFrames: Array<{ frame: JsonObject; publicationGeneration: number }>;
118
126
  /** Activity frames held until acknowledgement establishes exact prompt ownership. */
119
127
  deferredActivityFrames: JsonObject[];
120
128
  /** Clock reading of the last frame proven to belong to this prompt; watchdog silence baseline. */
@@ -143,6 +151,7 @@ interface PromptWaiter {
143
151
  }
144
152
 
145
153
  type PromptCorrelation = { commandId?: string; turnId?: string };
154
+ type PromptPhaseOwner = PromptWaiter | "background" | undefined;
146
155
 
147
156
  type BrokerConnection = { adapter: AcpSdkAdapter; client: SdkClient };
148
157
  type PendingAttachment = { epoch: number; task: Promise<void> };
@@ -156,10 +165,15 @@ type SessionRecord = {
156
165
  reconnectUnsubscribe: () => void;
157
166
  /** Per-session frame work queue; callbacks never race prompt ownership. */
158
167
  frameTail: Promise<void>;
168
+ /** Advances when an owned terminal retires all earlier frame publications. */
169
+ publicationGeneration: number;
159
170
  /** Monotonic at WebSocket ingress, before queued work begins. */
160
171
  inboundSequence: number;
161
172
  /** Updated at ingress so a prompt acknowledgement can distinguish a steer from a fresh turn. */
162
173
  busy: boolean;
174
+ backgroundBusy: boolean;
175
+ backgroundAnonymousCount: number;
176
+ backgroundCorrelations: PromptCorrelation[];
163
177
  /** Start/update args retained because tool_execution_end does not carry them. */
164
178
  toolArgs: Map<string, unknown>;
165
179
  /** Message projection state for correlationless session-scoped assistant events. */
@@ -182,7 +196,7 @@ function promptWaiterRetired(record: SessionRecord, waiter: PromptWaiter): boole
182
196
 
183
197
  type BrokerSession = {
184
198
  sessionId: string;
185
- locator?: { repo?: string };
199
+ locator?: { cwd?: string; worktreeRoot?: string | null; stateRoot?: string };
186
200
  live?: boolean;
187
201
  endpointGeneration?: number;
188
202
  endpointMtimeMs?: number;
@@ -272,13 +286,14 @@ export function paginateAcpSessions(
272
286
  offset: number,
273
287
  sessionMetadata: ReadonlyMap<string, { title?: string; updatedAt?: string }> = new Map(),
274
288
  ): ListSessionsResponse {
289
+ const canonicalCwd = cwd === undefined ? undefined : resolveEquivalentPath(cwd);
275
290
  const filtered = listed
276
291
  .map(value => object(value) as BrokerSession | undefined)
277
292
  .filter(
278
- (value): value is BrokerSession & { locator: { repo: string } } =>
279
- typeof value?.sessionId === "string" && typeof value.locator?.repo === "string",
293
+ (value): value is BrokerSession & { locator: { cwd: string } } =>
294
+ typeof value?.sessionId === "string" && typeof value.locator?.cwd === "string",
280
295
  )
281
- .filter(value => !cwd || value.locator.repo === cwd);
296
+ .filter(value => canonicalCwd === undefined || value.locator.cwd === canonicalCwd);
282
297
  const sessions = filtered.slice(offset, offset + SESSION_PAGE_SIZE).map(value => {
283
298
  const metadata = sessionMetadata.get(value.sessionId);
284
299
  const updatedAt =
@@ -291,7 +306,7 @@ export function paginateAcpSessions(
291
306
  : undefined;
292
307
  return {
293
308
  sessionId: value.sessionId,
294
- cwd: value.locator.repo,
309
+ cwd: value.locator.cwd,
295
310
  title:
296
311
  typeof metadata?.title === "string" && metadata.title
297
312
  ? metadata.title
@@ -490,7 +505,12 @@ function terminalOutcome(event: JsonObject): SdkPromptTerminalOutcome | undefine
490
505
  typeof outcome.message === "string" &&
491
506
  (outcome.provenance === "agent_failed" || outcome.provenance === "deadline")
492
507
  )
493
- return outcome as SdkPromptTerminalOutcome;
508
+ return {
509
+ kind: "failed",
510
+ code: outcome.code,
511
+ message: outcome.message,
512
+ provenance: outcome.provenance,
513
+ };
494
514
  return undefined;
495
515
  }
496
516
 
@@ -994,7 +1014,6 @@ export function acpPromptPayload(blocks: PromptRequest["prompt"]): {
994
1014
  text.push(block.text);
995
1015
  break;
996
1016
  case "image":
997
- if (block.uri) text.push(`[Image URI: ${block.uri}]`);
998
1017
  images.push({ data: block.data, mimeType: block.mimeType });
999
1018
  break;
1000
1019
  case "resource_link":
@@ -1027,7 +1046,6 @@ export function acpPromptPayload(blocks: PromptRequest["prompt"]): {
1027
1046
  "unsupported_content",
1028
1047
  `Unsupported embedded resource MIME type: ${mimeType}`,
1029
1048
  );
1030
- text.push(`[Resource: ${resource.uri}]\nMIME: ${mimeType}`);
1031
1049
  images.push({ data: resource.blob, mimeType });
1032
1050
  break;
1033
1051
  }
@@ -1038,7 +1056,7 @@ export function acpPromptPayload(blocks: PromptRequest["prompt"]): {
1038
1056
  }
1039
1057
  }
1040
1058
  if (text.length === 0 && images.length === 0)
1041
- throw new AcpSdkAdapterError("invalid_input", "ACP prompt must contain at least one supported content block.");
1059
+ throw new AcpSdkAdapterError("invalid_input", "Prompt must not be empty.");
1042
1060
  return { text: text.join("\n"), images };
1043
1061
  }
1044
1062
 
@@ -1183,6 +1201,18 @@ export class AcpAgent implements Agent {
1183
1201
  readonly #pendingRouterFrames = new Map<string, Record<string, unknown>[]>();
1184
1202
  #routerStartPromise: Promise<void> | undefined;
1185
1203
  readonly #sessions = new Map<string, SessionRecord>();
1204
+ /** Retain settled prompt identities across automatic transport reattachment. */
1205
+ readonly #retiredPromptCorrelations = new Map<string, PromptCorrelation[]>();
1206
+ /** Prevent a successor admission while a retired prompt can still reveal its identity. */
1207
+ readonly #retiredPromptAcknowledgements = new Set<string>();
1208
+ /** Ready terminal metadata writes retain ownership across same-id record replacement. */
1209
+ readonly #terminalMetadataTails = new Map<string, Promise<void>>();
1210
+ /** Unstreamed terminal text retains transcript ownership across same-id replacement. */
1211
+ readonly #finalTextTails = new Map<string, Promise<void>>();
1212
+ /** Generic failure diagnostics publish independently from authoritative terminal ingress. */
1213
+ readonly #failureDiagnosticTails = new Map<string, Promise<void>>();
1214
+ /** Prompt phase writes retain ordering across same-id record replacement. */
1215
+ readonly #promptPhaseTails = new Map<string, Promise<void>>();
1186
1216
  readonly #attaching = new Map<string, PendingAttachment>();
1187
1217
  readonly #resolvingExisting = new Map<string, PendingAttachment>();
1188
1218
  readonly #knownSessionCwds = new Map<string, string>();
@@ -1410,38 +1440,34 @@ export class AcpAgent implements Agent {
1410
1440
 
1411
1441
  async listSessions(params: ListSessionsRequest): Promise<ListSessionsResponse> {
1412
1442
  if (params.cwd) this.#assertAbsoluteCwd(params.cwd);
1443
+ const canonicalCwd = params.cwd ? await canonicalSessionCwd(params.cwd) : undefined;
1413
1444
  const adapter = await this.#brokerAdapter();
1414
1445
  const listing = await collectAcpSessionList(input => adapter.global("session.list", input));
1415
1446
  const listed = Array.isArray(listing.sessions) ? listing.sessions : [];
1416
1447
 
1417
- if (params.cwd) {
1448
+ if (canonicalCwd) {
1418
1449
  const discovered = new Set<string>();
1419
1450
  for (const session of listed) {
1420
1451
  const candidate = object(session) as BrokerSession | undefined;
1421
1452
  if (
1422
1453
  typeof candidate?.sessionId !== "string" ||
1423
- typeof candidate.locator?.repo !== "string" ||
1424
- path.resolve(candidate.locator.repo) !== path.resolve(params.cwd)
1454
+ typeof candidate.locator?.cwd !== "string" ||
1455
+ candidate.locator.cwd !== canonicalCwd
1425
1456
  )
1426
1457
  continue;
1427
1458
  if (discovered.has(candidate.sessionId))
1428
1459
  throw new AcpSdkAdapterError("conflict", `Broker returned duplicate session id: ${candidate.sessionId}`);
1429
1460
  discovered.add(candidate.sessionId);
1430
1461
  const knownCwd = this.#knownSessionCwds.get(candidate.sessionId);
1431
- if (knownCwd && path.resolve(knownCwd) !== path.resolve(params.cwd))
1462
+ if (knownCwd && (await canonicalSessionCwd(knownCwd)) !== canonicalCwd)
1432
1463
  throw new AcpSdkAdapterError(
1433
1464
  "conflict",
1434
1465
  `ACP session ${candidate.sessionId} has conflicting cwd authority.`,
1435
1466
  );
1436
- this.#knownSessionCwds.set(candidate.sessionId, params.cwd);
1467
+ this.#knownSessionCwds.set(candidate.sessionId, canonicalCwd);
1437
1468
  }
1438
1469
  }
1439
- return paginateAcpSessions(
1440
- listed,
1441
- params.cwd ?? undefined,
1442
- this.#cursor(params.cursor),
1443
- this.#knownSessionMetadata,
1444
- );
1470
+ return paginateAcpSessions(listed, canonicalCwd, this.#cursor(params.cursor), this.#knownSessionMetadata);
1445
1471
  }
1446
1472
 
1447
1473
  closeSession(params: CloseSessionRequest): Promise<CloseSessionResponse> {
@@ -1502,6 +1528,8 @@ export class AcpAgent implements Agent {
1502
1528
  this.#ownedSessionIds.delete(params.sessionId);
1503
1529
  this.#knownSessionMcpServers.delete(params.sessionId);
1504
1530
  this.#knownSessionMetadata.delete(params.sessionId);
1531
+ this.#retiredPromptCorrelations.delete(params.sessionId);
1532
+ this.#retiredPromptAcknowledgements.delete(params.sessionId);
1505
1533
  return {};
1506
1534
  }
1507
1535
  throw error;
@@ -1518,6 +1546,8 @@ export class AcpAgent implements Agent {
1518
1546
  this.#knownSessionMcpServers.delete(params.sessionId);
1519
1547
  this.#knownSessionMetadata.delete(params.sessionId);
1520
1548
  this.#pendingDeleteLocators.delete(params.sessionId);
1549
+ this.#retiredPromptCorrelations.delete(params.sessionId);
1550
+ this.#retiredPromptAcknowledgements.delete(params.sessionId);
1521
1551
  return {};
1522
1552
  } finally {
1523
1553
  this.#finishTeardown(params.sessionId);
@@ -1576,10 +1606,32 @@ export class AcpAgent implements Agent {
1576
1606
  if (!record) throw new AcpSdkAdapterError("not_found", `Unknown session, not found: ${params.sessionId}`);
1577
1607
  if (record.activePrompt) throw new AcpSdkAdapterError("conflict", "ACP session already has an active prompt.");
1578
1608
  if (record.authFailure) throw new AcpSdkAdapterError("authentication_failed", record.authFailure);
1579
- // A new turn starts uncancelled; a stale flag must never settle it as `cancelled`.
1580
- record.cancelRequested = false;
1609
+ if (this.#retiredPromptAcknowledgements.has(params.sessionId))
1610
+ throw new AcpSdkAdapterError(
1611
+ "conflict",
1612
+ "ACP session is still reconciling a previous prompt acknowledgement.",
1613
+ );
1614
+ if (this.#finalTextTails.has(params.sessionId))
1615
+ throw new AcpSdkAdapterError("conflict", "ACP session is still publishing the previous prompt's final text.");
1616
+ if (this.#terminalMetadataTails.has(params.sessionId))
1617
+ throw new AcpSdkAdapterError("conflict", "ACP session is still publishing the previous prompt's metadata.");
1618
+ if (this.#failureDiagnosticTails.has(params.sessionId))
1619
+ throw new AcpSdkAdapterError(
1620
+ "conflict",
1621
+ "ACP session is still publishing the previous prompt's failure diagnostic.",
1622
+ );
1623
+
1581
1624
  const payload = acpPromptPayload(params.prompt);
1582
1625
  const skillInvocation = acpSkillInvocation(params.prompt);
1626
+ if (!skillInvocation) {
1627
+ const promptError = validateRequiredPromptText("turn.prompt", {
1628
+ text: payload.text,
1629
+ images: payload.images,
1630
+ });
1631
+ if (promptError) throw new AcpSdkAdapterError(promptError.code, promptError.message);
1632
+ }
1633
+ // A new turn starts uncancelled; a stale flag must never settle it as `cancelled`.
1634
+ record.cancelRequested = false;
1583
1635
  if (isAcpUnavailableSlashCommand(payload.text)) {
1584
1636
  await this.#publishSessionUpdate(
1585
1637
  params.sessionId,
@@ -1619,14 +1671,18 @@ export class AcpAgent implements Agent {
1619
1671
  MAX_PROMPT_FRAME_BYTES / 1024,
1620
1672
  )} KiB transport limit. Attach a smaller or more compressed image.`,
1621
1673
  );
1674
+ record.publicationGeneration++;
1622
1675
  const { promise: response, resolve, reject } = Promise.withResolvers<PromptResponse>();
1623
1676
  const waiter: PromptWaiter = {
1624
1677
  acknowledged: false,
1625
1678
  dispatched: false,
1679
+ acknowledgementPending: false,
1626
1680
  boundary: record.inboundSequence,
1627
1681
  correlation: {},
1628
1682
  emittedAssistantText: "",
1629
1683
  settled: false,
1684
+ terminalReserved: false,
1685
+ failureDiagnostics: [],
1630
1686
  deferredFrames: [],
1631
1687
  deferredActivityFrames: [],
1632
1688
  lastFrameAt: this.#promptWatchdogClock.now(),
@@ -1644,13 +1700,20 @@ export class AcpAgent implements Agent {
1644
1700
  await record.adapter.ensureProviders();
1645
1701
  } catch (error) {
1646
1702
  if (waiter.settled || record.cancelRequested || record.activePrompt !== waiter) {
1703
+ this.#retiredPromptAcknowledgements.delete(params.sessionId);
1647
1704
  if (!waiter.settled) await this.#settleCancelledPrompt(params.sessionId, record, waiter);
1648
1705
  return await response;
1649
1706
  }
1650
- if (record.activePrompt === waiter) record.activePrompt = undefined;
1707
+ if (record.activePrompt === waiter) {
1708
+ record.activePrompt = undefined;
1709
+ record.busy = record.backgroundBusy;
1710
+ this.#retiredPromptAcknowledgements.delete(params.sessionId);
1711
+ void this.#publishPromptPhaseIdle(params.sessionId, record.adapter);
1712
+ }
1651
1713
  throw error;
1652
1714
  }
1653
1715
  if (waiter.settled || record.cancelRequested || record.activePrompt !== waiter) {
1716
+ this.#retiredPromptAcknowledgements.delete(params.sessionId);
1654
1717
  if (!waiter.settled) await this.#settleCancelledPrompt(params.sessionId, record, waiter);
1655
1718
  return await response;
1656
1719
  }
@@ -1681,6 +1744,7 @@ export class AcpAgent implements Agent {
1681
1744
  return await response;
1682
1745
  }
1683
1746
 
1747
+ waiter.acknowledgementPending = true;
1684
1748
  const acknowledgementTask = (async (): Promise<PromptResponse> => {
1685
1749
  if (waiter.settled || record.activePrompt !== waiter) return await response;
1686
1750
  const acknowledgement = skillInvocation
@@ -1696,20 +1760,33 @@ export class AcpAgent implements Agent {
1696
1760
  "invalid_prompt_acknowledgement",
1697
1761
  "SDK prompt acknowledgement must accept the prompt and include commandId and turnId.",
1698
1762
  );
1763
+ if (this.#hasRetiredPromptCorrelation(params.sessionId, record, acknowledgementCorrelation)) {
1764
+ try {
1765
+ const abortAcknowledgement = await record.adapter.cancel("turn");
1766
+ if (!isAbortAcknowledged(abortAcknowledgement, "turn"))
1767
+ throw new Error("SDK did not confirm retirement of the reused prompt correlation.");
1768
+ } catch (error) {
1769
+ await this.#failSession(
1770
+ params.sessionId,
1771
+ record.adapter,
1772
+ new AcpSdkAdapterError(
1773
+ "connection_closed",
1774
+ `ACP could not retire a prompt with a reused correlation: ${error instanceof Error ? error.message : String(error)}`,
1775
+ ),
1776
+ );
1777
+ throw error;
1778
+ }
1779
+ throw new AcpSdkAdapterError(
1780
+ "invalid_prompt_acknowledgement",
1781
+ "SDK prompt acknowledgement reused a settled commandId/turnId pair.",
1782
+ );
1783
+ }
1699
1784
  // Retain the acknowledgement ingress boundary with its complete correlation.
1700
1785
  waiter.boundary = record.inboundSequence;
1701
1786
  waiter.correlation = acknowledgementCorrelation;
1702
1787
  waiter.acknowledged = true;
1703
1788
  if (promptWaiterRetired(record, waiter)) {
1704
- if (
1705
- !record.settledPromptCorrelations.some(settled =>
1706
- correlationsExactlyMatch(settled, acknowledgementCorrelation),
1707
- )
1708
- ) {
1709
- record.settledPromptCorrelations.push(acknowledgementCorrelation);
1710
- while (record.settledPromptCorrelations.length > SETTLED_PROMPT_CORRELATION_RETENTION)
1711
- record.settledPromptCorrelations.shift();
1712
- }
1789
+ this.#rememberSettledPromptCorrelation(params.sessionId, record, acknowledgementCorrelation);
1713
1790
  return await response;
1714
1791
  }
1715
1792
  // Frames held while ownership was unknown belong to this prompt only when the
@@ -1718,18 +1795,47 @@ export class AcpAgent implements Agent {
1718
1795
  let observedDeferredActivity = false;
1719
1796
  for (const deferredFrame of deferredActivityFrames) {
1720
1797
  const deferredEvent = receivedSdkEvent(deferredFrame)?.event;
1721
- if (correlationsExactlyMatch(waiter.correlation, watchdogCorrelationFrom(deferredFrame, deferredEvent))) {
1798
+ const matchesWaiter = correlationsExactlyMatch(
1799
+ waiter.correlation,
1800
+ watchdogCorrelationFrom(deferredFrame, deferredEvent),
1801
+ );
1802
+ if (deferredEvent?.type === "agent_start" && !matchesWaiter) {
1803
+ const deferredCorrelation = sdkFrameCorrelation(deferredFrame, deferredEvent);
1804
+ const tombstoned =
1805
+ deferredCorrelation &&
1806
+ record.settledPromptCorrelations.some(settled =>
1807
+ correlationsExactlyMatch(settled, deferredCorrelation),
1808
+ );
1809
+ if (!tombstoned) {
1810
+ record.backgroundBusy = true;
1811
+ if (deferredCorrelation && hasCompleteCorrelation(deferredCorrelation)) {
1812
+ if (
1813
+ !record.backgroundCorrelations.some(owner =>
1814
+ correlationsExactlyMatch(owner, deferredCorrelation),
1815
+ )
1816
+ )
1817
+ record.backgroundCorrelations.push(deferredCorrelation);
1818
+ } else record.backgroundAnonymousCount++;
1819
+ }
1820
+ }
1821
+ if (matchesWaiter) {
1722
1822
  this.#observePromptActivity(waiter, deferredFrame);
1723
1823
  observedDeferredActivity = true;
1724
1824
  }
1725
1825
  }
1726
1826
  if (observedDeferredActivity) this.#armPromptWatchdog(params.sessionId, record, waiter);
1727
1827
  const deferred = waiter.deferredFrames.splice(0);
1728
- for (const deferredFrame of deferred) {
1828
+ for (const { frame: deferredFrame, publicationGeneration: deferredGeneration } of deferred) {
1729
1829
  const deferredEvent = receivedSdkEvent(deferredFrame)?.event;
1730
1830
  if (!deferredEvent) continue;
1731
1831
  const deferredCorrelation = sdkFrameCorrelation(deferredFrame, deferredEvent) ?? {};
1732
- const deferredIsTerminal = deferredEvent.type === "agent_end" || deferredEvent.type === "agent_failed";
1832
+ const deferredOutcome =
1833
+ deferredEvent.type === "agent_end" || deferredEvent.type === "agent_failed"
1834
+ ? terminalOutcome(deferredEvent)
1835
+ : undefined;
1836
+ const deferredIsTerminal =
1837
+ (deferredEvent.type === "agent_end" && deferredOutcome !== undefined) ||
1838
+ (deferredEvent.type === "agent_failed" && deferredOutcome?.kind === "failed");
1733
1839
  if (!hasCompleteCorrelation(deferredCorrelation)) {
1734
1840
  if (deferredIsTerminal)
1735
1841
  logDroppedPromptTerminal(
@@ -1742,6 +1848,18 @@ export class AcpAgent implements Agent {
1742
1848
  continue;
1743
1849
  }
1744
1850
  const matchesPrompt = correlationsExactlyMatch(waiter.correlation, deferredCorrelation);
1851
+ if (
1852
+ !matchesPrompt &&
1853
+ deferredIsTerminal &&
1854
+ record.backgroundCorrelations.some(owner => correlationsExactlyMatch(owner, deferredCorrelation))
1855
+ ) {
1856
+ record.backgroundCorrelations = record.backgroundCorrelations.filter(
1857
+ owner => !correlationsExactlyMatch(owner, deferredCorrelation),
1858
+ );
1859
+ record.backgroundBusy = record.backgroundAnonymousCount > 0 || record.backgroundCorrelations.length > 0;
1860
+ record.busy = true;
1861
+ continue;
1862
+ }
1745
1863
  if (
1746
1864
  !matchesPrompt &&
1747
1865
  record.settledPromptCorrelations.some(settled => correlationsExactlyMatch(settled, deferredCorrelation))
@@ -1758,17 +1876,26 @@ export class AcpAgent implements Agent {
1758
1876
  );
1759
1877
  continue;
1760
1878
  }
1879
+ if (deferredIsTerminal) {
1880
+ waiter.terminalReserved = true;
1881
+ clearPromptWatchdog(waiter);
1882
+ }
1761
1883
  const task = record.frameTail.then(
1762
- async () => await this.#handleSdkFrame(params.sessionId, record.adapter, deferredFrame),
1763
- );
1764
- record.frameTail = task.catch(
1765
- async error =>
1766
- await this.#failSession(params.sessionId, record.adapter, this.#frameProcessingFailure(error)),
1884
+ async () =>
1885
+ await this.#handleSdkFrame(params.sessionId, record.adapter, deferredFrame, deferredGeneration),
1767
1886
  );
1887
+ record.frameTail = task.catch(async error => {
1888
+ if (deferredGeneration !== record.publicationGeneration) return;
1889
+ if (record.activePrompt?.terminalReserved) return;
1890
+ await this.#failSession(params.sessionId, record.adapter, this.#frameProcessingFailure(error));
1891
+ });
1768
1892
  }
1769
- this.#settlePrompt(record, waiter);
1893
+ this.#settlePrompt(params.sessionId, record, waiter);
1770
1894
  return await response;
1771
- })();
1895
+ })().finally(() => {
1896
+ waiter.acknowledgementPending = false;
1897
+ this.#retiredPromptAcknowledgements.delete(params.sessionId);
1898
+ });
1772
1899
  // Settlement can win before the SDK answers `turn.prompt`; acknowledgement processing
1773
1900
  // must remain alive to tombstone its eventual correlation without holding the ACP caller.
1774
1901
  void acknowledgementTask.catch(() => undefined);
@@ -1781,15 +1908,15 @@ export class AcpAgent implements Agent {
1781
1908
  clearPromptWatchdog(waiter);
1782
1909
  waiter.terminal = undefined;
1783
1910
  waiter.settled = true;
1784
- if (record.activePrompt === waiter) record.activePrompt = undefined;
1911
+ if (record.activePrompt === waiter) {
1912
+ record.activePrompt = undefined;
1913
+ record.busy = record.backgroundBusy;
1914
+ void this.#publishPromptPhaseIdle(params.sessionId, record.adapter);
1915
+ }
1785
1916
  // Keep a late terminal for this (cancelled) turn closed, exactly like the
1786
1917
  // other settlement paths, so an aborted run's trailing frame can never
1787
1918
  // publish over a later prompt that reuses the identity.
1788
- if (hasCompleteCorrelation(waiter.correlation)) {
1789
- record.settledPromptCorrelations.push(waiter.correlation);
1790
- while (record.settledPromptCorrelations.length > SETTLED_PROMPT_CORRELATION_RETENTION)
1791
- record.settledPromptCorrelations.shift();
1792
- }
1919
+ this.#rememberSettledPromptCorrelation(params.sessionId, record, waiter.correlation);
1793
1920
  // A prompt cancelled before the SDK acknowledged it still ends this turn by
1794
1921
  // client request, and ACP is explicit: "Agents MUST catch these errors and
1795
1922
  // return the semantically meaningful `cancelled` stop reason, so that Clients
@@ -1800,7 +1927,6 @@ export class AcpAgent implements Agent {
1800
1927
  // The client's turn is settled by the return; the advisory idle publication
1801
1928
  // must not gate it and must still be attempted so the running phase is
1802
1929
  // released (gjcRunning:false) instead of spinning behind a settled cancel.
1803
- void this.#publishPromptPhaseIdle(params.sessionId, record.adapter);
1804
1930
  return { stopReason: "cancelled" };
1805
1931
  }
1806
1932
  throw error;
@@ -1845,7 +1971,9 @@ export class AcpAgent implements Agent {
1845
1971
  "abort_unacknowledged",
1846
1972
  "SDK did not acknowledge cancellation of the active prompt.",
1847
1973
  );
1848
- if (waiter) waiter.cancelAcknowledged = true;
1974
+ if (waiter) {
1975
+ waiter.cancelAcknowledged = true;
1976
+ }
1849
1977
  if (waiter && record.activePrompt !== waiter) {
1850
1978
  waiter.cancelAttemptResolve?.(true);
1851
1979
  return;
@@ -1858,10 +1986,16 @@ export class AcpAgent implements Agent {
1858
1986
  !waiter.settled
1859
1987
  ) {
1860
1988
  record.activePrompt = undefined;
1989
+ record.busy = record.backgroundBusy;
1990
+ record.cancelRequested = false;
1991
+ clearPromptWatchdog(waiter);
1861
1992
  waiter.settled = true;
1993
+ this.#fenceRetiredPromptAcknowledgement(params.sessionId, waiter);
1862
1994
  waiter.deferredFrames.length = 0;
1995
+ waiter.deferredActivityFrames.length = 0;
1863
1996
  waiter.terminal = undefined;
1864
1997
  waiter.resolve({ stopReason: "cancelled" });
1998
+ this.#flushFailureDiagnostics(params.sessionId, waiter, record.adapter);
1865
1999
  // Release the running phase for consistency with the resolved cancel; the
1866
2000
  // publication is advisory and must never gate the settlement above.
1867
2001
  void this.#publishPromptPhaseIdle(params.sessionId, record.adapter);
@@ -1926,20 +2060,19 @@ export class AcpAgent implements Agent {
1926
2060
  // The authoritative terminal wins whenever it arrives in time; this only runs
1927
2061
  // when nothing settled the prompt the client already asked to cancel.
1928
2062
  if (this.#sessions.get(id) !== record || record.activePrompt !== waiter || waiter.settled) return;
2063
+ if (waiter.terminalReserved) return;
1929
2064
  record.activePrompt = undefined;
2065
+ if (!record.backgroundBusy) record.busy = false;
1930
2066
  clearPromptWatchdog(waiter);
1931
2067
  record.cancelRequested = false;
1932
2068
  waiter.settled = true;
2069
+ this.#fenceRetiredPromptAcknowledgement(id, waiter);
1933
2070
  waiter.deferredFrames.length = 0;
1934
2071
  waiter.deferredActivityFrames.length = 0;
1935
2072
  waiter.terminal = undefined;
1936
2073
  // A late terminal for this turn must stay closed rather than publish over a
1937
2074
  // prompt the client has already been told is cancelled.
1938
- if (hasCompleteCorrelation(waiter.correlation)) {
1939
- record.settledPromptCorrelations.push(waiter.correlation);
1940
- while (record.settledPromptCorrelations.length > SETTLED_PROMPT_CORRELATION_RETENTION)
1941
- record.settledPromptCorrelations.shift();
1942
- }
2075
+ this.#rememberSettledPromptCorrelation(id, record, waiter.correlation);
1943
2076
  // The client's turn ends with the resolution, not with the advisory idle update.
1944
2077
  // Publishing the phase transition first made a backpressured ACP transport hold
1945
2078
  // the settlement hostage: `sessionUpdate` awaits the stream write, a client that
@@ -1947,6 +2080,7 @@ export class AcpAgent implements Agent {
1947
2080
  // ran — the acknowledged cancel left the prompt pending forever, the client
1948
2081
  // force-cancelled, and the next prompt collided with the stale foreground turn.
1949
2082
  waiter.resolve({ stopReason: "cancelled" });
2083
+ this.#flushFailureDiagnostics(id, waiter, record.adapter);
1950
2084
  void this.#publishPromptPhaseIdle(id, record.adapter);
1951
2085
  }
1952
2086
 
@@ -2137,14 +2271,15 @@ export class AcpAgent implements Agent {
2137
2271
  }
2138
2272
 
2139
2273
  async #scopedBrokerSession(id: string, cwd: string): Promise<BrokerSession | undefined> {
2274
+ const canonicalCwd = await canonicalSessionCwd(cwd);
2140
2275
  const adapter = await this.#brokerAdapter();
2141
- const result = await collectAcpSessionList(input => adapter.global("session.list", input), { cwd });
2276
+ const result = await collectAcpSessionList(input => adapter.global("session.list", input), { cwd: canonicalCwd });
2142
2277
 
2143
2278
  const matches: BrokerSession[] = [];
2144
2279
  for (const item of Array.isArray(result?.sessions) ? result.sessions : []) {
2145
2280
  const session = object(item) as BrokerSession | undefined;
2146
2281
  if (session?.sessionId !== id) continue;
2147
- if (typeof session.locator?.repo !== "string" || path.resolve(session.locator.repo) !== path.resolve(cwd))
2282
+ if (typeof session.locator?.cwd !== "string" || session.locator.cwd !== canonicalCwd)
2148
2283
  throw new AcpSdkAdapterError("conflict", `Broker returned conflicting session scope for ${id}.`);
2149
2284
  matches.push(session);
2150
2285
  }
@@ -2239,10 +2374,14 @@ export class AcpAgent implements Agent {
2239
2374
  unsubscribe: () => {},
2240
2375
  reconnectUnsubscribe: () => {},
2241
2376
  frameTail: Promise.resolve(),
2242
- settledPromptCorrelations: [],
2377
+ publicationGeneration: 0,
2378
+ settledPromptCorrelations: this.#retiredPromptCorrelations.get(id) ?? [],
2243
2379
  inboundSequence: 0,
2244
2380
  connectionId: adapter.connectionId,
2245
2381
  busy: false,
2382
+ backgroundBusy: false,
2383
+ backgroundAnonymousCount: 0,
2384
+ backgroundCorrelations: [],
2246
2385
  toolArgs: new Map(),
2247
2386
  };
2248
2387
  record.unsubscribe = adapter.onFrame(frame => this.#enqueueSdkFrame(id, adapter!, frame));
@@ -2292,6 +2431,40 @@ export class AcpAgent implements Agent {
2292
2431
  }
2293
2432
  }
2294
2433
 
2434
+ #rememberSettledPromptCorrelation(id: string, record: SessionRecord, correlation: PromptCorrelation): void {
2435
+ if (!hasCompleteCorrelation(correlation)) return;
2436
+ const shared = this.#retiredPromptCorrelations.get(id);
2437
+ const retained = shared ?? record.settledPromptCorrelations;
2438
+ if (shared && shared !== record.settledPromptCorrelations) {
2439
+ for (const candidate of record.settledPromptCorrelations) {
2440
+ if (
2441
+ hasCompleteCorrelation(candidate) &&
2442
+ !retained.some(settled => correlationsExactlyMatch(settled, candidate))
2443
+ )
2444
+ retained.push(candidate);
2445
+ }
2446
+ }
2447
+ if (!retained.some(settled => correlationsExactlyMatch(settled, correlation))) retained.push(correlation);
2448
+ while (retained.length > SETTLED_PROMPT_CORRELATION_RETENTION) retained.shift();
2449
+ record.settledPromptCorrelations = retained;
2450
+ this.#retiredPromptCorrelations.set(id, retained);
2451
+ }
2452
+
2453
+ #hasRetiredPromptCorrelation(id: string, record: SessionRecord, correlation: PromptCorrelation): boolean {
2454
+ return (this.#retiredPromptCorrelations.get(id) ?? record.settledPromptCorrelations).some(settled =>
2455
+ correlationsExactlyMatch(settled, correlation),
2456
+ );
2457
+ }
2458
+
2459
+ #fenceRetiredPromptAcknowledgement(id: string, waiter: PromptWaiter): void {
2460
+ if (waiter.dispatched && waiter.acknowledgementPending && !waiter.acknowledged)
2461
+ this.#retiredPromptAcknowledgements.add(id);
2462
+ }
2463
+
2464
+ #advanceTerminalGeneration(record: SessionRecord): void {
2465
+ record.publicationGeneration++;
2466
+ }
2467
+
2295
2468
  #recoverSessionAfterTransportFailure(id: string, adapter: AcpSdkAdapter, error: Error): void {
2296
2469
  const record = this.#sessions.get(id);
2297
2470
  if (!record || record.adapter !== adapter) return;
@@ -2325,6 +2498,8 @@ export class AcpAgent implements Agent {
2325
2498
  const mcpServers = this.#knownSessionMcpServers.get(id) ?? [];
2326
2499
  try {
2327
2500
  await this.#attachExisting(id, cwd, mcpServers);
2501
+ const current = this.#sessions.get(id);
2502
+ if (current) void this.#publishPromptPhase(id, current.adapter, this.#promptPhaseOwner(current));
2328
2503
  } catch (attachError) {
2329
2504
  const detail = attachError instanceof Error ? attachError.message : String(attachError);
2330
2505
  logger.warn(`ACP session ${id} auto-reattach after transport loss failed: ${detail}`);
@@ -2346,6 +2521,8 @@ export class AcpAgent implements Agent {
2346
2521
  this.#ownedSessionIds.delete(id);
2347
2522
  this.#knownSessionMcpServers.delete(id);
2348
2523
  this.#knownSessionMetadata.delete(id);
2524
+ this.#retiredPromptCorrelations.delete(id);
2525
+ this.#retiredPromptAcknowledgements.delete(id);
2349
2526
  }
2350
2527
 
2351
2528
  async #closeOwnedSession(id: string): Promise<CloseSessionResponse> {
@@ -2360,6 +2537,8 @@ export class AcpAgent implements Agent {
2360
2537
  this.#ownedSessionIds.delete(id);
2361
2538
  this.#knownSessionMcpServers.delete(id);
2362
2539
  this.#knownSessionMetadata.delete(id);
2540
+ this.#retiredPromptCorrelations.delete(id);
2541
+ this.#retiredPromptAcknowledgements.delete(id);
2363
2542
  return {};
2364
2543
  } finally {
2365
2544
  this.#finishTeardown(id);
@@ -2378,10 +2557,14 @@ export class AcpAgent implements Agent {
2378
2557
  try {
2379
2558
  this.#advanceSessionEpoch(id);
2380
2559
  if (record) {
2560
+ const waiter = record.activePrompt;
2561
+ if (waiter) {
2562
+ this.#rememberSettledPromptCorrelation(id, record, waiter.correlation);
2563
+ this.#fenceRetiredPromptAcknowledgement(id, waiter);
2564
+ }
2381
2565
  this.#sessions.delete(id);
2382
2566
  record.unsubscribe();
2383
2567
  record.reconnectUnsubscribe();
2384
- const waiter = record.activePrompt;
2385
2568
  record.activePrompt = undefined;
2386
2569
  // `session/close` is the client asking to end its own work, so the pending turn
2387
2570
  // settles as `cancelled` rather than surfacing a spurious error. ACP: "Agents
@@ -2441,7 +2624,11 @@ export class AcpAgent implements Agent {
2441
2624
  record.reconnectUnsubscribe();
2442
2625
  const waiter = record.activePrompt;
2443
2626
  record.activePrompt = undefined;
2444
- if (waiter) clearPromptWatchdog(waiter);
2627
+ if (waiter) {
2628
+ clearPromptWatchdog(waiter);
2629
+ this.#rememberSettledPromptCorrelation(id, record, waiter.correlation);
2630
+ this.#fenceRetiredPromptAcknowledgement(id, waiter);
2631
+ }
2445
2632
  waiter?.reject(error);
2446
2633
  try {
2447
2634
  await adapter.close();
@@ -2510,13 +2697,53 @@ export class AcpAgent implements Agent {
2510
2697
 
2511
2698
  #observeSessionActivity(record: SessionRecord, frame: JsonObject): void {
2512
2699
  if (frame.type === "activity") {
2513
- if (frame.state === "busy") record.busy = true;
2514
- else if (frame.state === "idle") record.busy = false;
2700
+ if (frame.state === "busy") {
2701
+ record.busy = true;
2702
+ if (!record.activePrompt) {
2703
+ record.backgroundBusy = true;
2704
+ record.backgroundAnonymousCount = Math.max(record.backgroundAnonymousCount, 1);
2705
+ }
2706
+ } else if (frame.state === "idle") {
2707
+ if (record.backgroundAnonymousCount > 0 || record.backgroundCorrelations.length > 0) {
2708
+ record.busy = true;
2709
+ record.backgroundBusy = true;
2710
+ return;
2711
+ }
2712
+ record.busy = false;
2713
+ record.backgroundBusy = false;
2714
+ record.backgroundAnonymousCount = 0;
2715
+ record.backgroundCorrelations.length = 0;
2716
+ }
2515
2717
  return;
2516
2718
  }
2517
2719
  const event = receivedSdkEvent(frame)?.event;
2518
- if (event?.type === "agent_start") record.busy = true;
2519
- else if (event?.type === "agent_end") record.busy = false;
2720
+ if (event?.type === "agent_start") {
2721
+ const correlation = sdkFrameCorrelation(frame, event);
2722
+ if (
2723
+ correlation &&
2724
+ record.settledPromptCorrelations.some(settled => correlationsExactlyMatch(settled, correlation))
2725
+ )
2726
+ return;
2727
+ if (
2728
+ record.activePrompt?.terminalReserved &&
2729
+ correlation &&
2730
+ correlationsExactlyMatch(record.activePrompt.correlation, correlation)
2731
+ )
2732
+ return;
2733
+ record.busy = true;
2734
+ if (
2735
+ !record.activePrompt ||
2736
+ record.activePrompt.terminalReserved ||
2737
+ (record.activePrompt.acknowledged &&
2738
+ (!correlation || !correlationsExactlyMatch(record.activePrompt.correlation, correlation)))
2739
+ ) {
2740
+ record.backgroundBusy = true;
2741
+ if (correlation && hasCompleteCorrelation(correlation)) {
2742
+ if (!record.backgroundCorrelations.some(owner => correlationsExactlyMatch(owner, correlation)))
2743
+ record.backgroundCorrelations.push(correlation);
2744
+ } else record.backgroundAnonymousCount++;
2745
+ }
2746
+ }
2520
2747
  }
2521
2748
 
2522
2749
  #frameProcessingFailure(error: unknown): AcpSdkAdapterError {
@@ -2553,7 +2780,7 @@ export class AcpAgent implements Agent {
2553
2780
  */
2554
2781
  #refreshPromptWatchdog(id: string, record: SessionRecord, frame: JsonObject): void {
2555
2782
  const waiter = record.activePrompt;
2556
- if (!waiter || waiter.settled) return;
2783
+ if (!waiter || waiter.settled || waiter.terminalReserved) return;
2557
2784
  const event = receivedSdkEvent(frame)?.event;
2558
2785
  const correlation = watchdogCorrelationFrom(frame, event);
2559
2786
  if (!waiter.acknowledged) {
@@ -2589,7 +2816,7 @@ export class AcpAgent implements Agent {
2589
2816
  * turn can no longer be observed, it does not cancel or tear down the session.
2590
2817
  */
2591
2818
  async #expirePromptWatchdog(id: string, record: SessionRecord, waiter: PromptWaiter): Promise<void> {
2592
- if (record.activePrompt !== waiter || waiter.settled) return;
2819
+ if (record.activePrompt !== waiter || waiter.settled || waiter.terminalReserved) return;
2593
2820
  const silenceMs = Math.max(0, this.#promptWatchdogClock.now() - waiter.lastFrameAt);
2594
2821
  const cause = this.#promptTransportGone(id, record) ?? "the SDK session host stopped producing frames";
2595
2822
  logger.error("acp_prompt_watchdog_expired", {
@@ -2619,20 +2846,48 @@ export class AcpAgent implements Agent {
2619
2846
  #enqueueSdkFrame(id: string, adapter: AcpSdkAdapter, frame: JsonObject): void {
2620
2847
  const record = this.#sessions.get(id);
2621
2848
  if (!record || record.adapter !== adapter) return;
2622
- if (typeof frame.connectionId === "string") record.connectionId = frame.connectionId;
2849
+ if (frame.type !== "hello" && frame.type !== "server_hello" && typeof frame.connectionId === "string")
2850
+ record.connectionId = frame.connectionId;
2623
2851
  // Ingress ordering is recorded before queued work begins.
2624
2852
  this.#observeSessionActivity(record, frame);
2625
2853
  // Correlation is checked at ingress before a prompt-owned frame may refresh the
2626
2854
  // watchdog, so queued processing cannot turn unrelated host traffic into turn liveness.
2627
2855
  this.#refreshPromptWatchdog(id, record, frame);
2856
+ const received = receivedSdkEvent(frame);
2857
+ const ingressEvent = received?.event;
2858
+ const ingressOutcome =
2859
+ ingressEvent?.type === "agent_end" || ingressEvent?.type === "agent_failed"
2860
+ ? terminalOutcome(ingressEvent)
2861
+ : undefined;
2862
+ const ingressIsTerminal =
2863
+ (ingressEvent?.type === "agent_end" && ingressOutcome !== undefined) ||
2864
+ (ingressEvent?.type === "agent_failed" && ingressOutcome?.kind === "failed");
2865
+ const ingressCorrelation = ingressEvent ? sdkFrameCorrelation(frame, ingressEvent) : undefined;
2866
+ if (
2867
+ ingressIsTerminal &&
2868
+ record.activePrompt?.acknowledged &&
2869
+ ingressCorrelation &&
2870
+ correlationsExactlyMatch(record.activePrompt.correlation, ingressCorrelation)
2871
+ ) {
2872
+ record.activePrompt.terminalReserved = true;
2873
+ clearPromptWatchdog(record.activePrompt);
2874
+ }
2628
2875
  ++record.inboundSequence;
2629
- const task = record.frameTail.then(async () => await this.#handleSdkFrame(id, adapter, frame));
2630
- record.frameTail = task.catch(
2631
- async error => await this.#failSession(id, adapter, this.#frameProcessingFailure(error)),
2632
- );
2876
+ const frameGeneration = record.publicationGeneration;
2877
+ const task = record.frameTail.then(async () => await this.#handleSdkFrame(id, adapter, frame, frameGeneration));
2878
+ record.frameTail = task.catch(async error => {
2879
+ if (frameGeneration !== record.publicationGeneration) return;
2880
+ if (record.activePrompt?.terminalReserved) return;
2881
+ await this.#failSession(id, adapter, this.#frameProcessingFailure(error));
2882
+ });
2633
2883
  }
2634
2884
 
2635
- async #handleSdkFrame(id: string, adapter: AcpSdkAdapter, frame: JsonObject): Promise<void> {
2885
+ async #handleSdkFrame(
2886
+ id: string,
2887
+ adapter: AcpSdkAdapter,
2888
+ frame: JsonObject,
2889
+ ingressPublicationGeneration?: number,
2890
+ ): Promise<void> {
2636
2891
  const record = this.#sessions.get(id);
2637
2892
  if (!record || record.adapter !== adapter) return;
2638
2893
  if ((frame.type === "hello" || frame.type === "server_hello") && typeof frame.connectionId === "string") {
@@ -2642,19 +2897,18 @@ export class AcpAgent implements Agent {
2642
2897
  const waiter = record.activePrompt;
2643
2898
  if (waiter && !waiter.settled && !waiter.terminal) {
2644
2899
  record.activePrompt = undefined;
2900
+ record.busy = record.backgroundBusy;
2645
2901
  clearPromptWatchdog(waiter);
2646
2902
  waiter.settled = true;
2647
- if (hasCorrelation(waiter.correlation)) {
2648
- record.settledPromptCorrelations.push(waiter.correlation);
2649
- while (record.settledPromptCorrelations.length > SETTLED_PROMPT_CORRELATION_RETENTION)
2650
- record.settledPromptCorrelations.shift();
2651
- }
2903
+ this.#rememberSettledPromptCorrelation(id, record, waiter.correlation);
2904
+ this.#fenceRetiredPromptAcknowledgement(id, waiter);
2652
2905
  waiter.reject(
2653
2906
  new AcpSdkAdapterError(
2654
2907
  "connection_closed",
2655
2908
  "The prompt owner connection was lost before completion.",
2656
2909
  ),
2657
2910
  );
2911
+ this.#flushFailureDiagnostics(id, waiter, record.adapter);
2658
2912
  // The turn is settled by the rejection; release the running phase
2659
2913
  // best-effort so a cancelled prompt is not left reporting working.
2660
2914
  void this.#publishPromptPhaseIdle(id, record.adapter);
@@ -2665,7 +2919,9 @@ export class AcpAgent implements Agent {
2665
2919
  const received = receivedSdkEvent(frame);
2666
2920
  if (!received) return;
2667
2921
  const { event, wirePayload } = received;
2668
- const isTerminal = event.type === "agent_end";
2922
+ let publicationGeneration = ingressPublicationGeneration ?? record.publicationGeneration;
2923
+ const outcome = event.type === "agent_end" || event.type === "agent_failed" ? terminalOutcome(event) : undefined;
2924
+ const isTerminal = event.type === "agent_end" || (event.type === "agent_failed" && outcome?.kind === "failed");
2669
2925
  if (event.type === "notice" && event.source === "autorouting" && typeof event.message === "string") {
2670
2926
  record.routingInactiveNotice = event.message;
2671
2927
  return;
@@ -2673,21 +2929,58 @@ export class AcpAgent implements Agent {
2673
2929
  const derivedCorrelation = sdkFrameCorrelation(frame, event);
2674
2930
  const correlation = derivedCorrelation ?? {};
2675
2931
  const activePrompt = record.activePrompt;
2676
- const outcome = isTerminal ? terminalOutcome(event) : undefined;
2932
+ let terminalPromptOwner: PromptWaiter | undefined;
2677
2933
  const settledCorrelation = record.settledPromptCorrelations.some(settled =>
2678
2934
  correlationsMatch(settled, correlation),
2679
2935
  );
2936
+ const ownedBackgroundCorrelation = hasCompleteCorrelation(correlation)
2937
+ ? record.backgroundCorrelations.find(owner => correlationsExactlyMatch(owner, correlation))
2938
+ : undefined;
2939
+ const ownsBackgroundTerminal =
2940
+ ownedBackgroundCorrelation !== undefined ||
2941
+ (!hasCorrelation(correlation) && record.backgroundAnonymousCount > 0);
2680
2942
  if (isTerminal) {
2943
+ if (!activePrompt) {
2944
+ if (!record.backgroundBusy || !ownsBackgroundTerminal || settledCorrelation) return;
2945
+ if (ownedBackgroundCorrelation)
2946
+ record.backgroundCorrelations = record.backgroundCorrelations.filter(
2947
+ owner => !correlationsExactlyMatch(owner, ownedBackgroundCorrelation),
2948
+ );
2949
+ else record.backgroundAnonymousCount--;
2950
+ record.backgroundBusy = record.backgroundAnonymousCount > 0 || record.backgroundCorrelations.length > 0;
2951
+ record.busy = record.backgroundBusy;
2952
+ await this.#publishPromptPhase(id, record.adapter, undefined);
2953
+ return;
2954
+ }
2955
+ const matchesActivePrompt =
2956
+ hasCompleteCorrelation(correlation) && correlationsExactlyMatch(activePrompt.correlation, correlation);
2957
+ if (
2958
+ record.backgroundBusy &&
2959
+ ownsBackgroundTerminal &&
2960
+ !matchesActivePrompt &&
2961
+ !settledCorrelation &&
2962
+ (!hasCorrelation(correlation) || hasCompleteCorrelation(correlation))
2963
+ ) {
2964
+ if (ownedBackgroundCorrelation)
2965
+ record.backgroundCorrelations = record.backgroundCorrelations.filter(
2966
+ owner => !correlationsExactlyMatch(owner, ownedBackgroundCorrelation),
2967
+ );
2968
+ else record.backgroundAnonymousCount--;
2969
+ record.backgroundBusy = record.backgroundAnonymousCount > 0 || record.backgroundCorrelations.length > 0;
2970
+ record.busy = true;
2971
+ await this.#publishPromptPhase(id, record.adapter, activePrompt);
2972
+ return;
2973
+ }
2681
2974
  // Terminal ownership requires a complete identity. Unowned, partial, and
2682
2975
  // duplicate terminals are never allowed to publish or query anything.
2683
- if (!activePrompt || activePrompt.settled) return;
2976
+ if (activePrompt.settled) return;
2684
2977
  if (!hasCompleteCorrelation(correlation)) {
2685
2978
  logDroppedPromptTerminal(id, event, "incomplete_correlation", correlation, activePrompt.correlation);
2686
2979
  return;
2687
2980
  }
2688
2981
  if (!activePrompt.acknowledged) {
2689
2982
  // Hold the entire frame until the prompt acknowledgement proves ownership.
2690
- activePrompt.deferredFrames.push(frame);
2983
+ activePrompt.deferredFrames.push({ frame, publicationGeneration });
2691
2984
  return;
2692
2985
  }
2693
2986
  const matchesPrompt = correlationsExactlyMatch(activePrompt.correlation, correlation);
@@ -2697,26 +2990,30 @@ export class AcpAgent implements Agent {
2697
2990
  return;
2698
2991
  }
2699
2992
  if (activePrompt.terminal) return;
2993
+ record.busy = record.backgroundBusy;
2994
+ terminalPromptOwner = activePrompt;
2995
+ this.#advanceTerminalGeneration(record);
2996
+ publicationGeneration = record.publicationGeneration;
2700
2997
  if (!outcome) {
2701
- const detail =
2702
- typeof (event as { error?: { message?: unknown } }).error?.message === "string"
2703
- ? (event as { error: { message: string } }).error.message
2704
- : "the prompt terminal omitted a valid normalized outcome";
2705
2998
  await this.#rejectPrompt(
2706
2999
  record,
2707
3000
  id,
2708
3001
  activePrompt,
2709
- new AcpSdkAdapterError("connection_closed", `ACP prompt terminal was invalid: ${detail}`),
3002
+ new AcpSdkAdapterError("connection_closed", "ACP prompt terminal was invalid or incomplete."),
2710
3003
  );
2711
3004
  return;
2712
3005
  }
2713
3006
  activePrompt.terminal = { outcome, correlation };
3007
+ // Failure diagnostics are useful but advisory. Settle before any mapped
3008
+ // session update can await a backpressured client transport; otherwise an
3009
+ // already-decided failure can still lose to the inactivity watchdog.
3010
+ this.#settlePrompt(id, record, activePrompt);
2714
3011
  }
2715
3012
  if (!isTerminal && derivedCorrelation === undefined) return;
2716
3013
  if (!isTerminal && hasCorrelation(correlation)) {
2717
3014
  if (!activePrompt || activePrompt.settled) return;
2718
3015
  if (!activePrompt.acknowledged) {
2719
- activePrompt.deferredFrames.push(frame);
3016
+ activePrompt.deferredFrames.push({ frame, publicationGeneration });
2720
3017
  return;
2721
3018
  }
2722
3019
  if (!correlationsExactlyMatch(activePrompt.correlation, correlation)) return;
@@ -2725,18 +3022,19 @@ export class AcpAgent implements Agent {
2725
3022
  // Frames for an already-settled correlation stay closed until an active prompt
2726
3023
  // acknowledges the exact same identity.
2727
3024
  if (activePrompt && !activePrompt.settled && !activePrompt.acknowledged) {
2728
- activePrompt.deferredFrames.push(frame);
3025
+ activePrompt.deferredFrames.push({ frame, publicationGeneration });
2729
3026
  return;
2730
3027
  }
2731
3028
  if (!activePrompt || activePrompt.settled || !correlationsMatch(activePrompt.correlation, correlation)) return;
2732
3029
  }
2733
3030
  const promptOwner =
2734
- activePrompt &&
3031
+ terminalPromptOwner ??
3032
+ (activePrompt &&
2735
3033
  !activePrompt.settled &&
2736
3034
  activePrompt.acknowledged &&
2737
3035
  correlationsExactlyMatch(activePrompt.correlation, correlation)
2738
3036
  ? activePrompt
2739
- : undefined;
3037
+ : undefined);
2740
3038
  const toolCallId = typeof event.toolCallId === "string" ? event.toolCallId : undefined;
2741
3039
  if (
2742
3040
  toolCallId &&
@@ -2759,7 +3057,9 @@ export class AcpAgent implements Agent {
2759
3057
  },
2760
3058
  };
2761
3059
  const notifications = wirePayload
2762
- ? mapAgentWireEventPayloadToAcpSessionUpdates(wirePayload as never, id, mapperOptions)
3060
+ ? event.type === "agent_failed" && !object(event.error)
3061
+ ? []
3062
+ : mapAgentWireEventPayloadToAcpSessionUpdates(wirePayload as never, id, mapperOptions)
2763
3063
  : event.type === "agent_failed" && object(event.error)
2764
3064
  ? mapAgentSessionEventToAcpSessionUpdates(event as unknown as AgentSessionEvent, id, mapperOptions)
2765
3065
  : [];
@@ -2770,7 +3070,21 @@ export class AcpAgent implements Agent {
2770
3070
  notification.update.content.type === "text"
2771
3071
  )
2772
3072
  promptOwner.emittedAssistantText += notification.update.content.text;
2773
- await this.#publishSessionUpdate(id, notification, adapter);
3073
+ // The prompt rejection carries the sanitized failure diagnostic. Publishing
3074
+ // a second session update after settlement would be stale as soon as the client
3075
+ // starts a replacement turn, and an in-flight transport write cannot be revoked.
3076
+ if (!(event.type === "agent_failed" && isTerminal))
3077
+ if (event.type === "agent_failed") {
3078
+ const diagnosticOwner = promptOwner ?? record.activePrompt;
3079
+ if (diagnosticOwner) diagnosticOwner.failureDiagnostics.push({ notification, publicationGeneration });
3080
+ else this.#scheduleFailureDiagnostic(id, notification, adapter, publicationGeneration);
3081
+ } else
3082
+ await this.#publishSessionUpdate(
3083
+ id,
3084
+ notification,
3085
+ adapter,
3086
+ promptOwner ? publicationGeneration : undefined,
3087
+ );
2774
3088
  }
2775
3089
  if (toolCallId && event.type === "tool_execution_end") record.toolArgs.delete(toolCallId);
2776
3090
  if (event.type === "agent_start") {
@@ -2785,15 +3099,64 @@ export class AcpAgent implements Agent {
2785
3099
  },
2786
3100
  },
2787
3101
  adapter,
3102
+ promptOwner ? publicationGeneration : undefined,
2788
3103
  );
2789
3104
  }
2790
3105
  if (event.type === "message_end" && object(event.message)?.role === "assistant") {
2791
3106
  if (promptOwner) promptOwner.messageProgress = undefined;
2792
3107
  else record.sessionMessageProgress = undefined;
2793
3108
  }
2794
- if (event.type === "agent_end") {
2795
- const finalText = typeof event.finalText === "string" ? event.finalText : "";
2796
- if (promptOwner && finalText) {
3109
+ if (isTerminal) this.#scheduleTerminalUpdates(id, adapter, publicationGeneration, event, promptOwner);
3110
+ }
3111
+
3112
+ #scheduleFailureDiagnostic(
3113
+ id: string,
3114
+ notification: SessionNotification,
3115
+ adapter: AcpSdkAdapter,
3116
+ publicationGeneration: number,
3117
+ ): void {
3118
+ const prior = this.#failureDiagnosticTails.get(id) ?? Promise.resolve();
3119
+ const task = prior.then(async () => {
3120
+ await Bun.sleep(0);
3121
+ const owner = this.#sessions.get(id);
3122
+ if (!owner || owner.adapter !== adapter) return;
3123
+ await this.#connection.sessionUpdate(notification);
3124
+ const finalTextTail = this.#finalTextTails.get(id);
3125
+ if (finalTextTail) await finalTextTail;
3126
+ const current = this.#sessions.get(id);
3127
+ if (current) await this.#publishPromptPhase(id, current.adapter, this.#promptPhaseOwner(current));
3128
+ });
3129
+ let tail: Promise<void>;
3130
+ tail = task
3131
+ .catch(error => {
3132
+ logger.warn("acp_failure_diagnostic_update_failed", { sessionId: id, error: String(error) });
3133
+ })
3134
+ .finally(() => {
3135
+ if (this.#failureDiagnosticTails.get(id) === tail) this.#failureDiagnosticTails.delete(id);
3136
+ });
3137
+ this.#failureDiagnosticTails.set(id, tail);
3138
+ }
3139
+
3140
+ #flushFailureDiagnostics(id: string, waiter: PromptWaiter, adapter: AcpSdkAdapter): void {
3141
+ for (const { notification, publicationGeneration } of waiter.failureDiagnostics)
3142
+ this.#scheduleFailureDiagnostic(id, notification, adapter, publicationGeneration);
3143
+ waiter.failureDiagnostics.length = 0;
3144
+ }
3145
+
3146
+ #scheduleTerminalUpdates(
3147
+ id: string,
3148
+ adapter: AcpSdkAdapter,
3149
+ publicationGeneration: number,
3150
+ event: JsonObject,
3151
+ promptOwner: PromptWaiter | undefined,
3152
+ ): void {
3153
+ const failedTerminal = event.type === "agent_failed";
3154
+ if (promptOwner) this.#flushFailureDiagnostics(id, promptOwner, adapter);
3155
+ let decorationStart = Promise.resolve();
3156
+ const finalText = typeof event.finalText === "string" ? event.finalText : "";
3157
+ if (promptOwner && finalText) {
3158
+ const finalTextTask = (async () => {
3159
+ await Bun.sleep(0);
2797
3160
  const resolution = resolveAcpFinalText(promptOwner.emittedAssistantText, finalText);
2798
3161
  if (resolution.kind === "emit") {
2799
3162
  promptOwner.emittedAssistantText += resolution.text;
@@ -2808,6 +3171,7 @@ export class AcpAgent implements Agent {
2808
3171
  },
2809
3172
  },
2810
3173
  adapter,
3174
+ publicationGeneration,
2811
3175
  );
2812
3176
  } else if (resolution.kind === "divergent") {
2813
3177
  logger.warn("acp_final_text_diverged", {
@@ -2818,19 +3182,27 @@ export class AcpAgent implements Agent {
2818
3182
  finalLength: resolution.final.text.length,
2819
3183
  });
2820
3184
  }
2821
- }
3185
+ })();
3186
+ let finalTextTail: Promise<void>;
3187
+ finalTextTail = finalTextTask
3188
+ .catch(error => {
3189
+ logger.warn("acp_terminal_update_failed", { sessionId: id, error: String(error) });
3190
+ })
3191
+ .finally(() => {
3192
+ if (this.#finalTextTails.get(id) === finalTextTail) this.#finalTextTails.delete(id);
3193
+ });
3194
+ this.#finalTextTails.set(id, finalTextTail);
3195
+ decorationStart = finalTextTail;
3196
+ }
3197
+ if (failedTerminal) {
3198
+ void decorationStart.then(async () => await this.#publishPromptPhaseIdle(id, adapter));
3199
+ return;
2822
3200
  }
2823
- // The terminal frame is the turn's end, so it settles the prompt before anything
2824
- // else is asked of the session host. `#emitEndOfTurnUpdates` queries `context.get`
2825
- // and `session.metadata`, and a host that stops producing the moment it publishes
2826
- // its terminal answers neither: sequencing settlement behind those advisory
2827
- // queries is what left a finished turn reported as running until the inactivity
2828
- // watchdog rescued it.
2829
- if (activePrompt) this.#settlePrompt(record, activePrompt);
2830
- // agent_failed is correlated diagnostic state while the run is still live.
2831
- // Only the authoritative agent_end boundary may publish end-of-turn usage,
2832
- // title, and idle integration updates.
2833
- if (event.type === "agent_end") await this.#emitEndOfTurnUpdates(id, adapter);
3201
+ void decorationStart
3202
+ .then(async () => await this.#emitEndOfTurnUpdates(id, adapter, publicationGeneration))
3203
+ .catch(error => {
3204
+ logger.warn("acp_terminal_update_failed", { sessionId: id, error: String(error) });
3205
+ });
2834
3206
  }
2835
3207
 
2836
3208
  async #rejectPrompt(
@@ -2838,26 +3210,26 @@ export class AcpAgent implements Agent {
2838
3210
  id: string,
2839
3211
  waiter: PromptWaiter,
2840
3212
  error: AcpSdkAdapterError,
3213
+ publishIdle = true,
2841
3214
  ): Promise<void> {
2842
3215
  if (record.activePrompt !== waiter || waiter.settled) return;
2843
3216
  record.activePrompt = undefined;
3217
+ if (!waiter.acknowledged) record.busy = record.backgroundBusy;
2844
3218
  clearPromptWatchdog(waiter);
2845
3219
  waiter.settled = true;
3220
+ this.#fenceRetiredPromptAcknowledgement(id, waiter);
2846
3221
  waiter.deferredFrames.length = 0;
2847
3222
  waiter.deferredActivityFrames.length = 0;
2848
3223
  waiter.terminal = undefined;
2849
- if (hasCompleteCorrelation(waiter.correlation)) {
2850
- record.settledPromptCorrelations.push(waiter.correlation);
2851
- while (record.settledPromptCorrelations.length > SETTLED_PROMPT_CORRELATION_RETENTION)
2852
- record.settledPromptCorrelations.shift();
2853
- }
3224
+ this.#rememberSettledPromptCorrelation(id, record, waiter.correlation);
2854
3225
  // The turn is over even though it ended badly, so the client's running phase has
2855
3226
  // to be released. Rejection is the settlement; the advisory idle publication must
2856
3227
  // never gate it (a backpressured transport would otherwise hold the rejection
2857
3228
  // forever). Skipping the idle publish entirely is what left a client composer
2858
3229
  // spinning on a turn that already produced its terminal.
2859
3230
  waiter.reject(error);
2860
- void this.#publishPromptPhaseIdle(id, record.adapter);
3231
+ this.#flushFailureDiagnostics(id, waiter, record.adapter);
3232
+ if (publishIdle) void this.#publishPromptPhaseIdle(id, record.adapter);
2861
3233
  }
2862
3234
 
2863
3235
  /**
@@ -2872,20 +3244,62 @@ export class AcpAgent implements Agent {
2872
3244
  async #publishPromptPhaseIdle(id: string, adapter: AcpSdkAdapter): Promise<void> {
2873
3245
  const record = this.#sessions.get(id);
2874
3246
  if (!record || record.adapter !== adapter) return;
3247
+ await this.#publishPromptPhase(id, adapter, this.#promptPhaseOwner(record));
3248
+ }
3249
+
3250
+ #promptPhaseOwner(record: SessionRecord): PromptPhaseOwner {
3251
+ return record.activePrompt ?? (record.busy ? "background" : undefined);
3252
+ }
3253
+
3254
+ async #publishPromptPhase(id: string, adapter: AcpSdkAdapter, observedPrompt: PromptPhaseOwner): Promise<void> {
3255
+ const pending = this.#promptPhaseTails.get(id);
3256
+ if (pending) {
3257
+ await pending;
3258
+ const current = this.#sessions.get(id);
3259
+ if (current) await this.#publishPromptPhase(id, current.adapter, this.#promptPhaseOwner(current));
3260
+ return;
3261
+ }
3262
+ const task = this.#runPromptPhase(id, adapter, observedPrompt);
3263
+ let tail: Promise<void>;
3264
+ tail = task.finally(() => {
3265
+ if (this.#promptPhaseTails.get(id) === tail) this.#promptPhaseTails.delete(id);
3266
+ });
3267
+ this.#promptPhaseTails.set(id, tail);
3268
+ await tail;
3269
+ }
3270
+
3271
+ async #runPromptPhase(id: string, adapter: AcpSdkAdapter, observedPrompt: PromptPhaseOwner): Promise<void> {
2875
3272
  try {
2876
- await this.#connection.sessionUpdate({
2877
- sessionId: id,
2878
- update: {
2879
- sessionUpdate: "session_info_update",
2880
- _meta: { gjcPhase: "idle", running: false, gjcRunning: false },
2881
- },
2882
- });
3273
+ await Bun.sleep(0);
3274
+ for (;;) {
3275
+ await this.#connection.sessionUpdate({
3276
+ sessionId: id,
3277
+ update: {
3278
+ sessionUpdate: "session_info_update",
3279
+ ...(observedPrompt ? { updatedAt: new Date().toISOString() } : {}),
3280
+ _meta: observedPrompt
3281
+ ? { gjcPhase: "working", running: true, gjcRunning: true }
3282
+ : { gjcPhase: "idle", running: false, gjcRunning: false },
3283
+ },
3284
+ });
3285
+ // A prompt may start or finish while any phase write is backpressured.
3286
+ // Continue until the last published phase describes the current waiter.
3287
+ const current = this.#sessions.get(id);
3288
+ if (!current) return;
3289
+ if (current.adapter !== adapter) {
3290
+ void this.#publishPromptPhase(id, current.adapter, this.#promptPhaseOwner(current));
3291
+ return;
3292
+ }
3293
+ const currentOwner = this.#promptPhaseOwner(current);
3294
+ if (currentOwner === observedPrompt) return;
3295
+ observedPrompt = currentOwner;
3296
+ }
2883
3297
  } catch {
2884
3298
  // The client transport is gone; there is no phase left to restore.
2885
3299
  }
2886
3300
  }
2887
3301
 
2888
- #settlePrompt(record: SessionRecord, waiter: PromptWaiter): void {
3302
+ #settlePrompt(id: string, record: SessionRecord, waiter: PromptWaiter): void {
2889
3303
  if (record.activePrompt !== waiter || waiter.settled || !waiter.acknowledged || !waiter.terminal) return;
2890
3304
  // A terminal captured before acknowledgement is only this prompt's terminal when the
2891
3305
  // eventual acknowledgement correlates with it; otherwise it belonged to an earlier prompt.
@@ -2896,12 +3310,8 @@ export class AcpAgent implements Agent {
2896
3310
  record.activePrompt = undefined;
2897
3311
  clearPromptWatchdog(waiter);
2898
3312
  waiter.settled = true;
2899
- if (hasCorrelation(waiter.correlation)) {
2900
- record.settledPromptCorrelations.push(waiter.correlation);
2901
- while (record.settledPromptCorrelations.length > SETTLED_PROMPT_CORRELATION_RETENTION)
2902
- record.settledPromptCorrelations.shift();
2903
- }
2904
3313
  const { outcome } = waiter.terminal;
3314
+ this.#rememberSettledPromptCorrelation(id, record, waiter.correlation);
2905
3315
  if (outcome.kind === "stopped") {
2906
3316
  waiter.resolve({ stopReason: outcome.reason });
2907
3317
  return;
@@ -2909,7 +3319,7 @@ export class AcpAgent implements Agent {
2909
3319
  waiter.reject(new AcpSdkAdapterError(outcome.code, outcome.message));
2910
3320
  }
2911
3321
 
2912
- async #emitEndOfTurnUpdates(id: string, adapter: AcpSdkAdapter): Promise<void> {
3322
+ async #emitEndOfTurnUpdates(id: string, adapter: AcpSdkAdapter, publicationGeneration: number): Promise<void> {
2913
3323
  let usage: JsonObject | undefined;
2914
3324
  let title: string | undefined;
2915
3325
  try {
@@ -2930,42 +3340,70 @@ export class AcpAgent implements Agent {
2930
3340
  }
2931
3341
  // The prompt settled before these queries were asked, so a host that answers late
2932
3342
  // must not report the session idle after the next turn has already started.
2933
- if (this.#sessions.get(id)?.activePrompt) return;
2934
- if (typeof usage?.tokens === "number" && typeof usage.contextWindow === "number") {
3343
+ const ownsCurrentGeneration = (): boolean => {
3344
+ const current = this.#sessions.get(id);
3345
+ return Boolean(
3346
+ current &&
3347
+ current.adapter === adapter &&
3348
+ current.publicationGeneration === publicationGeneration &&
3349
+ !current.activePrompt &&
3350
+ !current.busy,
3351
+ );
3352
+ };
3353
+ if (!ownsCurrentGeneration()) return;
3354
+ const record = this.#sessions.get(id);
3355
+ if (!record || record.adapter !== adapter) return;
3356
+ const observedPhaseOwner = this.#promptPhaseOwner(record);
3357
+ const publishTask = (async () => {
3358
+ if (typeof usage?.tokens === "number" && typeof usage.contextWindow === "number") {
3359
+ await this.#publishSessionUpdate(
3360
+ id,
3361
+ {
3362
+ sessionId: id,
3363
+ update: {
3364
+ sessionUpdate: "usage_update",
3365
+ size: usage.contextWindow,
3366
+ used: usage.tokens,
3367
+ },
3368
+ },
3369
+ adapter,
3370
+ publicationGeneration,
3371
+ );
3372
+ }
3373
+ if (!ownsCurrentGeneration()) return;
3374
+ const updatedAt = new Date().toISOString();
3375
+ this.#knownSessionMetadata.set(id, { ...(title ? { title } : {}), updatedAt });
2935
3376
  await this.#publishSessionUpdate(
2936
3377
  id,
2937
3378
  {
2938
3379
  sessionId: id,
2939
3380
  update: {
2940
- sessionUpdate: "usage_update",
2941
- size: usage.contextWindow,
2942
- used: usage.tokens,
3381
+ sessionUpdate: "session_info_update",
3382
+ ...(title ? { title } : {}),
3383
+ updatedAt,
3384
+ _meta: { gjcPhase: "idle", running: false, gjcRunning: false },
2943
3385
  },
2944
3386
  },
2945
3387
  adapter,
3388
+ publicationGeneration,
2946
3389
  );
2947
- }
2948
- const updatedAt = new Date().toISOString();
2949
- this.#knownSessionMetadata.set(id, { ...(title ? { title } : {}), updatedAt });
2950
- await this.#publishSessionUpdate(
2951
- id,
2952
- {
2953
- sessionId: id,
2954
- update: {
2955
- sessionUpdate: "session_info_update",
2956
- ...(title ? { title } : {}),
2957
- updatedAt,
2958
- _meta: { gjcPhase: "idle", running: false, gjcRunning: false },
2959
- },
2960
- },
2961
- adapter,
2962
- );
3390
+ const current = this.#sessions.get(id);
3391
+ if (current && this.#promptPhaseOwner(current) !== observedPhaseOwner)
3392
+ await this.#publishPromptPhase(id, current.adapter, this.#promptPhaseOwner(current));
3393
+ })();
3394
+ let metadataTail: Promise<void>;
3395
+ metadataTail = publishTask.finally(() => {
3396
+ if (this.#terminalMetadataTails.get(id) === metadataTail) this.#terminalMetadataTails.delete(id);
3397
+ });
3398
+ this.#terminalMetadataTails.set(id, metadataTail);
3399
+ await metadataTail;
2963
3400
  }
2964
3401
 
2965
3402
  async #publishSessionUpdate(
2966
3403
  id: string,
2967
3404
  notification: SessionNotification,
2968
3405
  expectedAdapter?: AcpSdkAdapter,
3406
+ publicationGeneration?: number,
2969
3407
  ): Promise<void> {
2970
3408
  // A session that is gone has no update channel, so this is a drop and not a failure:
2971
3409
  // whoever owned the frame is the one that ended the session. Callers whose bookkeeping
@@ -2974,7 +3412,27 @@ export class AcpAgent implements Agent {
2974
3412
  if (!record || (expectedAdapter && record.adapter !== expectedAdapter)) return;
2975
3413
  try {
2976
3414
  await this.#connection.sessionUpdate(notification);
3415
+ const current = this.#sessions.get(id);
3416
+ if (
3417
+ current &&
3418
+ ((expectedAdapter && current.adapter !== expectedAdapter) ||
3419
+ (publicationGeneration !== undefined && publicationGeneration !== current.publicationGeneration))
3420
+ ) {
3421
+ void this.#publishPromptPhase(id, current.adapter, this.#promptPhaseOwner(current));
3422
+ }
2977
3423
  } catch (error) {
3424
+ // A validated terminal can retire this publication while it is blocked in
3425
+ // the client transport. Its failure belongs to the drained prompt and must
3426
+ // not tear down a successor that has since taken session ownership.
3427
+ const current = this.#sessions.get(id);
3428
+ if (
3429
+ publicationGeneration !== undefined &&
3430
+ current &&
3431
+ current.adapter === record.adapter &&
3432
+ current.activePrompt?.terminalReserved
3433
+ )
3434
+ return;
3435
+ if (publicationGeneration !== undefined && publicationGeneration !== record.publicationGeneration) return;
2978
3436
  const failure = this.#frameProcessingFailure(error);
2979
3437
  await this.#failSession(id, record.adapter, failure);
2980
3438
  throw failure;
@@ -3510,17 +3968,8 @@ export class AcpAgent implements Agent {
3510
3968
  record.adapter,
3511
3969
  );
3512
3970
  }
3513
- await this.#publishSessionUpdate(
3514
- id,
3515
- {
3516
- sessionId: id,
3517
- update: {
3518
- sessionUpdate: "session_info_update",
3519
- _meta: { gjcPhase: "idle", running: false, gjcRunning: false },
3520
- },
3521
- },
3522
- record.adapter,
3523
- );
3971
+ const current = this.#sessions.get(id);
3972
+ if (current) await this.#publishPromptPhase(id, current.adapter, this.#promptPhaseOwner(current));
3524
3973
  })().catch(() => undefined);
3525
3974
  });
3526
3975
  }
@@ -3679,6 +4128,12 @@ export class AcpAgent implements Agent {
3679
4128
  this.#ownedSessionIds.clear();
3680
4129
  this.#knownSessionMcpServers.clear();
3681
4130
  this.#knownSessionMetadata.clear();
4131
+ this.#retiredPromptCorrelations.clear();
4132
+ this.#retiredPromptAcknowledgements.clear();
4133
+ this.#terminalMetadataTails.clear();
4134
+ this.#finalTextTails.clear();
4135
+ this.#failureDiagnosticTails.clear();
4136
+ this.#promptPhaseTails.clear();
3682
4137
  this.#pendingDeleteLocators.clear();
3683
4138
  this.#pendingCloseIdempotencyKeys.clear();
3684
4139
  if (this.#lifecycleOperations.size === 0) this.#lifecycleOperations.clear();