@gajae-code/coding-agent 0.15.6 → 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 (167) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/dist/types/cli/args.d.ts +2 -0
  3. package/dist/types/cli/root-flags.d.ts +14 -0
  4. package/dist/types/cli/update-cli.d.ts +13 -0
  5. package/dist/types/cli.d.ts +7 -0
  6. package/dist/types/commands/launch.d.ts +10 -0
  7. package/dist/types/config/autorouting-tier-map.d.ts +24 -0
  8. package/dist/types/config/file-lock.d.ts +5 -0
  9. package/dist/types/config/model-registry.d.ts +6 -0
  10. package/dist/types/config/settings-schema.d.ts +10 -1
  11. package/dist/types/config/settings.d.ts +2 -0
  12. package/dist/types/coordinator-mcp/projection-scan.d.ts +8 -0
  13. package/dist/types/crash/record-loader.d.ts +15 -0
  14. package/dist/types/gjc-runtime/launch-worktree-reservation.d.ts +21 -0
  15. package/dist/types/gjc-runtime/managed-owner-supervisor.d.ts +2 -0
  16. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +66 -1
  17. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +1 -0
  18. package/dist/types/gjc-runtime/tmux-sessions.d.ts +27 -0
  19. package/dist/types/master-mode/context.d.ts +21 -0
  20. package/dist/types/master-mode/first-request.d.ts +34 -0
  21. package/dist/types/master-mode/snapshot.d.ts +32 -0
  22. package/dist/types/modes/components/frictionless-onboarding-selector.d.ts +10 -0
  23. package/dist/types/modes/components/oauth-selector.d.ts +1 -0
  24. package/dist/types/modes/components/status-line/model-name.d.ts +18 -0
  25. package/dist/types/modes/components/status-line/priority-row.d.ts +34 -0
  26. package/dist/types/modes/components/status-line/segments.d.ts +10 -0
  27. package/dist/types/modes/print-mode.d.ts +17 -0
  28. package/dist/types/modes/ui-language.d.ts +7 -1
  29. package/dist/types/sdk/broker/broker.d.ts +77 -1
  30. package/dist/types/sdk/broker/endpoint-authority.d.ts +24 -0
  31. package/dist/types/sdk/broker/index.d.ts +3 -0
  32. package/dist/types/sdk/broker/lifecycle.d.ts +26 -0
  33. package/dist/types/sdk/broker/master-capability.d.ts +10 -0
  34. package/dist/types/sdk/broker/session-index.d.ts +40 -11
  35. package/dist/types/sdk/broker/session-scope.d.ts +72 -0
  36. package/dist/types/sdk/broker/spawn-authority.d.ts +201 -0
  37. package/dist/types/sdk/broker/spawn-substrate.d.ts +30 -0
  38. package/dist/types/sdk/broker/state-version.d.ts +4 -1
  39. package/dist/types/sdk/broker/worktree-occupancy.d.ts +28 -0
  40. package/dist/types/sdk/bus/chat-daemon-control.d.ts +3 -0
  41. package/dist/types/sdk/bus/index.d.ts +6 -0
  42. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  43. package/dist/types/sdk/cli/index.d.ts +1 -0
  44. package/dist/types/sdk/cli/master-cli.d.ts +63 -0
  45. package/dist/types/sdk/cli/rows.d.ts +4 -3
  46. package/dist/types/sdk/cli/session-cli.d.ts +18 -2
  47. package/dist/types/sdk/client/client.d.ts +3 -2
  48. package/dist/types/sdk/host/control/dispatch.d.ts +10 -0
  49. package/dist/types/sdk/host/control/index.d.ts +1 -1
  50. package/dist/types/sdk/host/control/operations.d.ts +2 -0
  51. package/dist/types/sdk/host/control/runtime-gate.d.ts +21 -7
  52. package/dist/types/sdk/host/host.d.ts +10 -0
  53. package/dist/types/sdk/host/session-runtime.d.ts +52 -0
  54. package/dist/types/sdk/lifecycle/broker-client.d.ts +6 -0
  55. package/dist/types/sdk/lifecycle/service.d.ts +15 -3
  56. package/dist/types/sdk/lifecycle-session.d.ts +2 -0
  57. package/dist/types/sdk/router/session-router.d.ts +2 -0
  58. package/dist/types/sdk/session.d.ts +16 -0
  59. package/dist/types/sdk/startup-capability.d.ts +2 -1
  60. package/dist/types/session/agent-session.d.ts +5 -0
  61. package/dist/types/session/credential-store-errors.d.ts +1 -0
  62. package/dist/types/session/session-manager.d.ts +65 -0
  63. package/dist/types/session/yield-queue.d.ts +5 -4
  64. package/dist/types/setup/frictionless-onboarding.d.ts +1 -0
  65. package/dist/types/tools/bash.d.ts +4 -0
  66. package/dist/types/tools/gh.d.ts +8 -0
  67. package/dist/types/tools/index.d.ts +4 -0
  68. package/package.json +7 -7
  69. package/scripts/generate-sdk-operation-inventory.ts +6 -0
  70. package/scripts/verify-gjc-sdk-canonicalization.ts +35 -1
  71. package/src/cli/accounts-cli.ts +9 -5
  72. package/src/cli/args.ts +17 -0
  73. package/src/cli/fast-help.ts +1 -0
  74. package/src/cli/root-flags.ts +5 -0
  75. package/src/cli/update-cli.ts +77 -11
  76. package/src/commands/launch.ts +86 -18
  77. package/src/commands/sdk.ts +113 -21
  78. package/src/config/autorouting-tier-map.ts +12 -0
  79. package/src/config/file-lock.ts +158 -83
  80. package/src/config/model-preset-registry.ts +1 -5
  81. package/src/config/model-profiles.ts +5 -5
  82. package/src/config/model-registry.ts +23 -0
  83. package/src/config/model-resolver.ts +5 -0
  84. package/src/config/settings-schema.ts +8 -1
  85. package/src/config/settings.ts +5 -0
  86. package/src/coordinator-mcp/projection-scan.ts +21 -0
  87. package/src/coordinator-mcp/question-state.ts +9 -11
  88. package/src/coordinator-mcp/server.ts +94 -20
  89. package/src/crash/index-store.ts +2 -0
  90. package/src/crash/record-loader.ts +10 -0
  91. package/src/gjc-runtime/launch-worktree-reservation.ts +242 -0
  92. package/src/gjc-runtime/managed-owner-supervisor.ts +63 -51
  93. package/src/gjc-runtime/session-state-lock.ts +79 -6
  94. package/src/gjc-runtime/session-state-sidecar.ts +929 -23
  95. package/src/gjc-runtime/tmux-owner-isolation-cli.ts +4 -7
  96. package/src/gjc-runtime/tmux-owner-isolation.ts +2 -2
  97. package/src/gjc-runtime/tmux-sessions.ts +252 -13
  98. package/src/hooks/skill-state.ts +6 -0
  99. package/src/internal-urls/docs-index.generated.ts +7 -7
  100. package/src/internal-urls/issue-pr-protocol.ts +4 -2
  101. package/src/main.ts +57 -2
  102. package/src/master-mode/context.ts +50 -0
  103. package/src/master-mode/first-request.ts +65 -0
  104. package/src/master-mode/snapshot.ts +205 -0
  105. package/src/modes/acp/acp-agent.ts +605 -156
  106. package/src/modes/components/footer.ts +4 -2
  107. package/src/modes/components/frictionless-onboarding-selector.ts +48 -2
  108. package/src/modes/components/oauth-selector.ts +6 -1
  109. package/src/modes/components/settings-selector.ts +1 -0
  110. package/src/modes/components/status-line/model-name.ts +53 -0
  111. package/src/modes/components/status-line/priority-row.ts +107 -0
  112. package/src/modes/components/status-line/segments.ts +23 -9
  113. package/src/modes/components/tool-status-header.ts +151 -15
  114. package/src/modes/controllers/input-controller.ts +28 -0
  115. package/src/modes/controllers/selector-controller.ts +191 -96
  116. package/src/modes/print-mode.ts +64 -0
  117. package/src/modes/ui-language.ts +49 -2
  118. package/src/prompts/system/master-mode.md +28 -0
  119. package/src/sdk/broker/broker.ts +1636 -26
  120. package/src/sdk/broker/endpoint-authority.ts +86 -0
  121. package/src/sdk/broker/ensure.ts +5 -0
  122. package/src/sdk/broker/index.ts +3 -0
  123. package/src/sdk/broker/lifecycle.ts +186 -41
  124. package/src/sdk/broker/master-capability.ts +232 -0
  125. package/src/sdk/broker/session-index.ts +121 -17
  126. package/src/sdk/broker/session-scope.ts +272 -0
  127. package/src/sdk/broker/spawn-authority.ts +1031 -0
  128. package/src/sdk/broker/spawn-substrate.ts +548 -0
  129. package/src/sdk/broker/state-version.ts +26 -14
  130. package/src/sdk/broker/transport.ts +1 -0
  131. package/src/sdk/broker/worktree-occupancy.ts +90 -0
  132. package/src/sdk/bus/chat-daemon-control.ts +5 -2
  133. package/src/sdk/bus/chat-daemon-runtime.ts +35 -4
  134. package/src/sdk/bus/index.ts +51 -4
  135. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  136. package/src/sdk/cli/index.ts +1 -0
  137. package/src/sdk/cli/master-cli.ts +259 -0
  138. package/src/sdk/cli/rows.ts +11 -8
  139. package/src/sdk/cli/session-cli.ts +264 -5
  140. package/src/sdk/client/client.ts +14 -8
  141. package/src/sdk/host/control/dispatch.ts +99 -38
  142. package/src/sdk/host/control/index.ts +2 -0
  143. package/src/sdk/host/control/operations.ts +2 -0
  144. package/src/sdk/host/control/runtime-gate.ts +203 -11
  145. package/src/sdk/host/host.ts +27 -2
  146. package/src/sdk/host/session-runtime.test.ts +210 -6
  147. package/src/sdk/host/session-runtime.ts +291 -49
  148. package/src/sdk/lifecycle/broker-client.ts +28 -0
  149. package/src/sdk/lifecycle/service.ts +279 -20
  150. package/src/sdk/lifecycle-session.ts +10 -2
  151. package/src/sdk/protocol/operation-inventory.generated.json +51 -0
  152. package/src/sdk/protocol/operation-registry.ts +13 -3
  153. package/src/sdk/router/session-router.ts +67 -59
  154. package/src/sdk/session.ts +357 -155
  155. package/src/sdk/startup-capability.ts +1 -0
  156. package/src/session/agent-session.ts +640 -103
  157. package/src/session/blob-store.ts +15 -5
  158. package/src/session/credential-store-errors.ts +2 -0
  159. package/src/session/internal/managed-session-storage.ts +153 -78
  160. package/src/session/session-manager.ts +337 -24
  161. package/src/session/yield-queue.ts +24 -10
  162. package/src/setup/frictionless-onboarding.ts +2 -0
  163. package/src/telemetry/events.ts +1138 -12
  164. package/src/tools/bash.ts +256 -42
  165. package/src/tools/gh.ts +104 -48
  166. package/src/tools/github-cache.ts +2 -2
  167. package/src/tools/index.ts +4 -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,6 +49,7 @@ 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";
@@ -106,6 +107,8 @@ interface PromptWaiter {
106
107
  acknowledged: boolean;
107
108
  /** True once turn.prompt / skill.invoke has been sent; cancel must not fake-settle after this. */
108
109
  dispatched: boolean;
110
+ /** True only while the dispatched control request can still reveal its correlation. */
111
+ acknowledgementPending: boolean;
109
112
 
110
113
  /** Highest inbound frame sequence already observed when the prompt was acknowledged. */
111
114
  boundary: number;
@@ -114,8 +117,12 @@ interface PromptWaiter {
114
117
  emittedAssistantText: string;
115
118
  settled: boolean;
116
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 }>;
117
124
  /** Frames for an already-settled correlation held until acknowledgement resolves ownership. */
118
- deferredFrames: JsonObject[];
125
+ deferredFrames: Array<{ frame: JsonObject; publicationGeneration: number }>;
119
126
  /** Activity frames held until acknowledgement establishes exact prompt ownership. */
120
127
  deferredActivityFrames: JsonObject[];
121
128
  /** Clock reading of the last frame proven to belong to this prompt; watchdog silence baseline. */
@@ -144,6 +151,7 @@ interface PromptWaiter {
144
151
  }
145
152
 
146
153
  type PromptCorrelation = { commandId?: string; turnId?: string };
154
+ type PromptPhaseOwner = PromptWaiter | "background" | undefined;
147
155
 
148
156
  type BrokerConnection = { adapter: AcpSdkAdapter; client: SdkClient };
149
157
  type PendingAttachment = { epoch: number; task: Promise<void> };
@@ -157,10 +165,15 @@ type SessionRecord = {
157
165
  reconnectUnsubscribe: () => void;
158
166
  /** Per-session frame work queue; callbacks never race prompt ownership. */
159
167
  frameTail: Promise<void>;
168
+ /** Advances when an owned terminal retires all earlier frame publications. */
169
+ publicationGeneration: number;
160
170
  /** Monotonic at WebSocket ingress, before queued work begins. */
161
171
  inboundSequence: number;
162
172
  /** Updated at ingress so a prompt acknowledgement can distinguish a steer from a fresh turn. */
163
173
  busy: boolean;
174
+ backgroundBusy: boolean;
175
+ backgroundAnonymousCount: number;
176
+ backgroundCorrelations: PromptCorrelation[];
164
177
  /** Start/update args retained because tool_execution_end does not carry them. */
165
178
  toolArgs: Map<string, unknown>;
166
179
  /** Message projection state for correlationless session-scoped assistant events. */
@@ -183,7 +196,7 @@ function promptWaiterRetired(record: SessionRecord, waiter: PromptWaiter): boole
183
196
 
184
197
  type BrokerSession = {
185
198
  sessionId: string;
186
- locator?: { repo?: string };
199
+ locator?: { cwd?: string; worktreeRoot?: string | null; stateRoot?: string };
187
200
  live?: boolean;
188
201
  endpointGeneration?: number;
189
202
  endpointMtimeMs?: number;
@@ -273,13 +286,14 @@ export function paginateAcpSessions(
273
286
  offset: number,
274
287
  sessionMetadata: ReadonlyMap<string, { title?: string; updatedAt?: string }> = new Map(),
275
288
  ): ListSessionsResponse {
289
+ const canonicalCwd = cwd === undefined ? undefined : resolveEquivalentPath(cwd);
276
290
  const filtered = listed
277
291
  .map(value => object(value) as BrokerSession | undefined)
278
292
  .filter(
279
- (value): value is BrokerSession & { locator: { repo: string } } =>
280
- 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",
281
295
  )
282
- .filter(value => !cwd || value.locator.repo === cwd);
296
+ .filter(value => canonicalCwd === undefined || value.locator.cwd === canonicalCwd);
283
297
  const sessions = filtered.slice(offset, offset + SESSION_PAGE_SIZE).map(value => {
284
298
  const metadata = sessionMetadata.get(value.sessionId);
285
299
  const updatedAt =
@@ -292,7 +306,7 @@ export function paginateAcpSessions(
292
306
  : undefined;
293
307
  return {
294
308
  sessionId: value.sessionId,
295
- cwd: value.locator.repo,
309
+ cwd: value.locator.cwd,
296
310
  title:
297
311
  typeof metadata?.title === "string" && metadata.title
298
312
  ? metadata.title
@@ -491,7 +505,12 @@ function terminalOutcome(event: JsonObject): SdkPromptTerminalOutcome | undefine
491
505
  typeof outcome.message === "string" &&
492
506
  (outcome.provenance === "agent_failed" || outcome.provenance === "deadline")
493
507
  )
494
- return outcome as SdkPromptTerminalOutcome;
508
+ return {
509
+ kind: "failed",
510
+ code: outcome.code,
511
+ message: outcome.message,
512
+ provenance: outcome.provenance,
513
+ };
495
514
  return undefined;
496
515
  }
497
516
 
@@ -1182,6 +1201,18 @@ export class AcpAgent implements Agent {
1182
1201
  readonly #pendingRouterFrames = new Map<string, Record<string, unknown>[]>();
1183
1202
  #routerStartPromise: Promise<void> | undefined;
1184
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>>();
1185
1216
  readonly #attaching = new Map<string, PendingAttachment>();
1186
1217
  readonly #resolvingExisting = new Map<string, PendingAttachment>();
1187
1218
  readonly #knownSessionCwds = new Map<string, string>();
@@ -1409,38 +1440,34 @@ export class AcpAgent implements Agent {
1409
1440
 
1410
1441
  async listSessions(params: ListSessionsRequest): Promise<ListSessionsResponse> {
1411
1442
  if (params.cwd) this.#assertAbsoluteCwd(params.cwd);
1443
+ const canonicalCwd = params.cwd ? await canonicalSessionCwd(params.cwd) : undefined;
1412
1444
  const adapter = await this.#brokerAdapter();
1413
1445
  const listing = await collectAcpSessionList(input => adapter.global("session.list", input));
1414
1446
  const listed = Array.isArray(listing.sessions) ? listing.sessions : [];
1415
1447
 
1416
- if (params.cwd) {
1448
+ if (canonicalCwd) {
1417
1449
  const discovered = new Set<string>();
1418
1450
  for (const session of listed) {
1419
1451
  const candidate = object(session) as BrokerSession | undefined;
1420
1452
  if (
1421
1453
  typeof candidate?.sessionId !== "string" ||
1422
- typeof candidate.locator?.repo !== "string" ||
1423
- path.resolve(candidate.locator.repo) !== path.resolve(params.cwd)
1454
+ typeof candidate.locator?.cwd !== "string" ||
1455
+ candidate.locator.cwd !== canonicalCwd
1424
1456
  )
1425
1457
  continue;
1426
1458
  if (discovered.has(candidate.sessionId))
1427
1459
  throw new AcpSdkAdapterError("conflict", `Broker returned duplicate session id: ${candidate.sessionId}`);
1428
1460
  discovered.add(candidate.sessionId);
1429
1461
  const knownCwd = this.#knownSessionCwds.get(candidate.sessionId);
1430
- if (knownCwd && path.resolve(knownCwd) !== path.resolve(params.cwd))
1462
+ if (knownCwd && (await canonicalSessionCwd(knownCwd)) !== canonicalCwd)
1431
1463
  throw new AcpSdkAdapterError(
1432
1464
  "conflict",
1433
1465
  `ACP session ${candidate.sessionId} has conflicting cwd authority.`,
1434
1466
  );
1435
- this.#knownSessionCwds.set(candidate.sessionId, params.cwd);
1467
+ this.#knownSessionCwds.set(candidate.sessionId, canonicalCwd);
1436
1468
  }
1437
1469
  }
1438
- return paginateAcpSessions(
1439
- listed,
1440
- params.cwd ?? undefined,
1441
- this.#cursor(params.cursor),
1442
- this.#knownSessionMetadata,
1443
- );
1470
+ return paginateAcpSessions(listed, canonicalCwd, this.#cursor(params.cursor), this.#knownSessionMetadata);
1444
1471
  }
1445
1472
 
1446
1473
  closeSession(params: CloseSessionRequest): Promise<CloseSessionResponse> {
@@ -1501,6 +1528,8 @@ export class AcpAgent implements Agent {
1501
1528
  this.#ownedSessionIds.delete(params.sessionId);
1502
1529
  this.#knownSessionMcpServers.delete(params.sessionId);
1503
1530
  this.#knownSessionMetadata.delete(params.sessionId);
1531
+ this.#retiredPromptCorrelations.delete(params.sessionId);
1532
+ this.#retiredPromptAcknowledgements.delete(params.sessionId);
1504
1533
  return {};
1505
1534
  }
1506
1535
  throw error;
@@ -1517,6 +1546,8 @@ export class AcpAgent implements Agent {
1517
1546
  this.#knownSessionMcpServers.delete(params.sessionId);
1518
1547
  this.#knownSessionMetadata.delete(params.sessionId);
1519
1548
  this.#pendingDeleteLocators.delete(params.sessionId);
1549
+ this.#retiredPromptCorrelations.delete(params.sessionId);
1550
+ this.#retiredPromptAcknowledgements.delete(params.sessionId);
1520
1551
  return {};
1521
1552
  } finally {
1522
1553
  this.#finishTeardown(params.sessionId);
@@ -1575,6 +1606,21 @@ export class AcpAgent implements Agent {
1575
1606
  if (!record) throw new AcpSdkAdapterError("not_found", `Unknown session, not found: ${params.sessionId}`);
1576
1607
  if (record.activePrompt) throw new AcpSdkAdapterError("conflict", "ACP session already has an active prompt.");
1577
1608
  if (record.authFailure) throw new AcpSdkAdapterError("authentication_failed", record.authFailure);
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
+
1578
1624
  const payload = acpPromptPayload(params.prompt);
1579
1625
  const skillInvocation = acpSkillInvocation(params.prompt);
1580
1626
  if (!skillInvocation) {
@@ -1625,14 +1671,18 @@ export class AcpAgent implements Agent {
1625
1671
  MAX_PROMPT_FRAME_BYTES / 1024,
1626
1672
  )} KiB transport limit. Attach a smaller or more compressed image.`,
1627
1673
  );
1674
+ record.publicationGeneration++;
1628
1675
  const { promise: response, resolve, reject } = Promise.withResolvers<PromptResponse>();
1629
1676
  const waiter: PromptWaiter = {
1630
1677
  acknowledged: false,
1631
1678
  dispatched: false,
1679
+ acknowledgementPending: false,
1632
1680
  boundary: record.inboundSequence,
1633
1681
  correlation: {},
1634
1682
  emittedAssistantText: "",
1635
1683
  settled: false,
1684
+ terminalReserved: false,
1685
+ failureDiagnostics: [],
1636
1686
  deferredFrames: [],
1637
1687
  deferredActivityFrames: [],
1638
1688
  lastFrameAt: this.#promptWatchdogClock.now(),
@@ -1650,13 +1700,20 @@ export class AcpAgent implements Agent {
1650
1700
  await record.adapter.ensureProviders();
1651
1701
  } catch (error) {
1652
1702
  if (waiter.settled || record.cancelRequested || record.activePrompt !== waiter) {
1703
+ this.#retiredPromptAcknowledgements.delete(params.sessionId);
1653
1704
  if (!waiter.settled) await this.#settleCancelledPrompt(params.sessionId, record, waiter);
1654
1705
  return await response;
1655
1706
  }
1656
- 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
+ }
1657
1713
  throw error;
1658
1714
  }
1659
1715
  if (waiter.settled || record.cancelRequested || record.activePrompt !== waiter) {
1716
+ this.#retiredPromptAcknowledgements.delete(params.sessionId);
1660
1717
  if (!waiter.settled) await this.#settleCancelledPrompt(params.sessionId, record, waiter);
1661
1718
  return await response;
1662
1719
  }
@@ -1687,6 +1744,7 @@ export class AcpAgent implements Agent {
1687
1744
  return await response;
1688
1745
  }
1689
1746
 
1747
+ waiter.acknowledgementPending = true;
1690
1748
  const acknowledgementTask = (async (): Promise<PromptResponse> => {
1691
1749
  if (waiter.settled || record.activePrompt !== waiter) return await response;
1692
1750
  const acknowledgement = skillInvocation
@@ -1702,20 +1760,33 @@ export class AcpAgent implements Agent {
1702
1760
  "invalid_prompt_acknowledgement",
1703
1761
  "SDK prompt acknowledgement must accept the prompt and include commandId and turnId.",
1704
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
+ }
1705
1784
  // Retain the acknowledgement ingress boundary with its complete correlation.
1706
1785
  waiter.boundary = record.inboundSequence;
1707
1786
  waiter.correlation = acknowledgementCorrelation;
1708
1787
  waiter.acknowledged = true;
1709
1788
  if (promptWaiterRetired(record, waiter)) {
1710
- if (
1711
- !record.settledPromptCorrelations.some(settled =>
1712
- correlationsExactlyMatch(settled, acknowledgementCorrelation),
1713
- )
1714
- ) {
1715
- record.settledPromptCorrelations.push(acknowledgementCorrelation);
1716
- while (record.settledPromptCorrelations.length > SETTLED_PROMPT_CORRELATION_RETENTION)
1717
- record.settledPromptCorrelations.shift();
1718
- }
1789
+ this.#rememberSettledPromptCorrelation(params.sessionId, record, acknowledgementCorrelation);
1719
1790
  return await response;
1720
1791
  }
1721
1792
  // Frames held while ownership was unknown belong to this prompt only when the
@@ -1724,18 +1795,47 @@ export class AcpAgent implements Agent {
1724
1795
  let observedDeferredActivity = false;
1725
1796
  for (const deferredFrame of deferredActivityFrames) {
1726
1797
  const deferredEvent = receivedSdkEvent(deferredFrame)?.event;
1727
- 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) {
1728
1822
  this.#observePromptActivity(waiter, deferredFrame);
1729
1823
  observedDeferredActivity = true;
1730
1824
  }
1731
1825
  }
1732
1826
  if (observedDeferredActivity) this.#armPromptWatchdog(params.sessionId, record, waiter);
1733
1827
  const deferred = waiter.deferredFrames.splice(0);
1734
- for (const deferredFrame of deferred) {
1828
+ for (const { frame: deferredFrame, publicationGeneration: deferredGeneration } of deferred) {
1735
1829
  const deferredEvent = receivedSdkEvent(deferredFrame)?.event;
1736
1830
  if (!deferredEvent) continue;
1737
1831
  const deferredCorrelation = sdkFrameCorrelation(deferredFrame, deferredEvent) ?? {};
1738
- 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");
1739
1839
  if (!hasCompleteCorrelation(deferredCorrelation)) {
1740
1840
  if (deferredIsTerminal)
1741
1841
  logDroppedPromptTerminal(
@@ -1748,6 +1848,18 @@ export class AcpAgent implements Agent {
1748
1848
  continue;
1749
1849
  }
1750
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
+ }
1751
1863
  if (
1752
1864
  !matchesPrompt &&
1753
1865
  record.settledPromptCorrelations.some(settled => correlationsExactlyMatch(settled, deferredCorrelation))
@@ -1764,17 +1876,26 @@ export class AcpAgent implements Agent {
1764
1876
  );
1765
1877
  continue;
1766
1878
  }
1879
+ if (deferredIsTerminal) {
1880
+ waiter.terminalReserved = true;
1881
+ clearPromptWatchdog(waiter);
1882
+ }
1767
1883
  const task = record.frameTail.then(
1768
- async () => await this.#handleSdkFrame(params.sessionId, record.adapter, deferredFrame),
1769
- );
1770
- record.frameTail = task.catch(
1771
- async error =>
1772
- await this.#failSession(params.sessionId, record.adapter, this.#frameProcessingFailure(error)),
1884
+ async () =>
1885
+ await this.#handleSdkFrame(params.sessionId, record.adapter, deferredFrame, deferredGeneration),
1773
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
+ });
1774
1892
  }
1775
- this.#settlePrompt(record, waiter);
1893
+ this.#settlePrompt(params.sessionId, record, waiter);
1776
1894
  return await response;
1777
- })();
1895
+ })().finally(() => {
1896
+ waiter.acknowledgementPending = false;
1897
+ this.#retiredPromptAcknowledgements.delete(params.sessionId);
1898
+ });
1778
1899
  // Settlement can win before the SDK answers `turn.prompt`; acknowledgement processing
1779
1900
  // must remain alive to tombstone its eventual correlation without holding the ACP caller.
1780
1901
  void acknowledgementTask.catch(() => undefined);
@@ -1787,15 +1908,15 @@ export class AcpAgent implements Agent {
1787
1908
  clearPromptWatchdog(waiter);
1788
1909
  waiter.terminal = undefined;
1789
1910
  waiter.settled = true;
1790
- 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
+ }
1791
1916
  // Keep a late terminal for this (cancelled) turn closed, exactly like the
1792
1917
  // other settlement paths, so an aborted run's trailing frame can never
1793
1918
  // publish over a later prompt that reuses the identity.
1794
- if (hasCompleteCorrelation(waiter.correlation)) {
1795
- record.settledPromptCorrelations.push(waiter.correlation);
1796
- while (record.settledPromptCorrelations.length > SETTLED_PROMPT_CORRELATION_RETENTION)
1797
- record.settledPromptCorrelations.shift();
1798
- }
1919
+ this.#rememberSettledPromptCorrelation(params.sessionId, record, waiter.correlation);
1799
1920
  // A prompt cancelled before the SDK acknowledged it still ends this turn by
1800
1921
  // client request, and ACP is explicit: "Agents MUST catch these errors and
1801
1922
  // return the semantically meaningful `cancelled` stop reason, so that Clients
@@ -1806,7 +1927,6 @@ export class AcpAgent implements Agent {
1806
1927
  // The client's turn is settled by the return; the advisory idle publication
1807
1928
  // must not gate it and must still be attempted so the running phase is
1808
1929
  // released (gjcRunning:false) instead of spinning behind a settled cancel.
1809
- void this.#publishPromptPhaseIdle(params.sessionId, record.adapter);
1810
1930
  return { stopReason: "cancelled" };
1811
1931
  }
1812
1932
  throw error;
@@ -1851,7 +1971,9 @@ export class AcpAgent implements Agent {
1851
1971
  "abort_unacknowledged",
1852
1972
  "SDK did not acknowledge cancellation of the active prompt.",
1853
1973
  );
1854
- if (waiter) waiter.cancelAcknowledged = true;
1974
+ if (waiter) {
1975
+ waiter.cancelAcknowledged = true;
1976
+ }
1855
1977
  if (waiter && record.activePrompt !== waiter) {
1856
1978
  waiter.cancelAttemptResolve?.(true);
1857
1979
  return;
@@ -1864,10 +1986,16 @@ export class AcpAgent implements Agent {
1864
1986
  !waiter.settled
1865
1987
  ) {
1866
1988
  record.activePrompt = undefined;
1989
+ record.busy = record.backgroundBusy;
1990
+ record.cancelRequested = false;
1991
+ clearPromptWatchdog(waiter);
1867
1992
  waiter.settled = true;
1993
+ this.#fenceRetiredPromptAcknowledgement(params.sessionId, waiter);
1868
1994
  waiter.deferredFrames.length = 0;
1995
+ waiter.deferredActivityFrames.length = 0;
1869
1996
  waiter.terminal = undefined;
1870
1997
  waiter.resolve({ stopReason: "cancelled" });
1998
+ this.#flushFailureDiagnostics(params.sessionId, waiter, record.adapter);
1871
1999
  // Release the running phase for consistency with the resolved cancel; the
1872
2000
  // publication is advisory and must never gate the settlement above.
1873
2001
  void this.#publishPromptPhaseIdle(params.sessionId, record.adapter);
@@ -1932,20 +2060,19 @@ export class AcpAgent implements Agent {
1932
2060
  // The authoritative terminal wins whenever it arrives in time; this only runs
1933
2061
  // when nothing settled the prompt the client already asked to cancel.
1934
2062
  if (this.#sessions.get(id) !== record || record.activePrompt !== waiter || waiter.settled) return;
2063
+ if (waiter.terminalReserved) return;
1935
2064
  record.activePrompt = undefined;
2065
+ if (!record.backgroundBusy) record.busy = false;
1936
2066
  clearPromptWatchdog(waiter);
1937
2067
  record.cancelRequested = false;
1938
2068
  waiter.settled = true;
2069
+ this.#fenceRetiredPromptAcknowledgement(id, waiter);
1939
2070
  waiter.deferredFrames.length = 0;
1940
2071
  waiter.deferredActivityFrames.length = 0;
1941
2072
  waiter.terminal = undefined;
1942
2073
  // A late terminal for this turn must stay closed rather than publish over a
1943
2074
  // prompt the client has already been told is cancelled.
1944
- if (hasCompleteCorrelation(waiter.correlation)) {
1945
- record.settledPromptCorrelations.push(waiter.correlation);
1946
- while (record.settledPromptCorrelations.length > SETTLED_PROMPT_CORRELATION_RETENTION)
1947
- record.settledPromptCorrelations.shift();
1948
- }
2075
+ this.#rememberSettledPromptCorrelation(id, record, waiter.correlation);
1949
2076
  // The client's turn ends with the resolution, not with the advisory idle update.
1950
2077
  // Publishing the phase transition first made a backpressured ACP transport hold
1951
2078
  // the settlement hostage: `sessionUpdate` awaits the stream write, a client that
@@ -1953,6 +2080,7 @@ export class AcpAgent implements Agent {
1953
2080
  // ran — the acknowledged cancel left the prompt pending forever, the client
1954
2081
  // force-cancelled, and the next prompt collided with the stale foreground turn.
1955
2082
  waiter.resolve({ stopReason: "cancelled" });
2083
+ this.#flushFailureDiagnostics(id, waiter, record.adapter);
1956
2084
  void this.#publishPromptPhaseIdle(id, record.adapter);
1957
2085
  }
1958
2086
 
@@ -2143,14 +2271,15 @@ export class AcpAgent implements Agent {
2143
2271
  }
2144
2272
 
2145
2273
  async #scopedBrokerSession(id: string, cwd: string): Promise<BrokerSession | undefined> {
2274
+ const canonicalCwd = await canonicalSessionCwd(cwd);
2146
2275
  const adapter = await this.#brokerAdapter();
2147
- const result = await collectAcpSessionList(input => adapter.global("session.list", input), { cwd });
2276
+ const result = await collectAcpSessionList(input => adapter.global("session.list", input), { cwd: canonicalCwd });
2148
2277
 
2149
2278
  const matches: BrokerSession[] = [];
2150
2279
  for (const item of Array.isArray(result?.sessions) ? result.sessions : []) {
2151
2280
  const session = object(item) as BrokerSession | undefined;
2152
2281
  if (session?.sessionId !== id) continue;
2153
- 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)
2154
2283
  throw new AcpSdkAdapterError("conflict", `Broker returned conflicting session scope for ${id}.`);
2155
2284
  matches.push(session);
2156
2285
  }
@@ -2245,10 +2374,14 @@ export class AcpAgent implements Agent {
2245
2374
  unsubscribe: () => {},
2246
2375
  reconnectUnsubscribe: () => {},
2247
2376
  frameTail: Promise.resolve(),
2248
- settledPromptCorrelations: [],
2377
+ publicationGeneration: 0,
2378
+ settledPromptCorrelations: this.#retiredPromptCorrelations.get(id) ?? [],
2249
2379
  inboundSequence: 0,
2250
2380
  connectionId: adapter.connectionId,
2251
2381
  busy: false,
2382
+ backgroundBusy: false,
2383
+ backgroundAnonymousCount: 0,
2384
+ backgroundCorrelations: [],
2252
2385
  toolArgs: new Map(),
2253
2386
  };
2254
2387
  record.unsubscribe = adapter.onFrame(frame => this.#enqueueSdkFrame(id, adapter!, frame));
@@ -2298,6 +2431,40 @@ export class AcpAgent implements Agent {
2298
2431
  }
2299
2432
  }
2300
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
+
2301
2468
  #recoverSessionAfterTransportFailure(id: string, adapter: AcpSdkAdapter, error: Error): void {
2302
2469
  const record = this.#sessions.get(id);
2303
2470
  if (!record || record.adapter !== adapter) return;
@@ -2331,6 +2498,8 @@ export class AcpAgent implements Agent {
2331
2498
  const mcpServers = this.#knownSessionMcpServers.get(id) ?? [];
2332
2499
  try {
2333
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));
2334
2503
  } catch (attachError) {
2335
2504
  const detail = attachError instanceof Error ? attachError.message : String(attachError);
2336
2505
  logger.warn(`ACP session ${id} auto-reattach after transport loss failed: ${detail}`);
@@ -2352,6 +2521,8 @@ export class AcpAgent implements Agent {
2352
2521
  this.#ownedSessionIds.delete(id);
2353
2522
  this.#knownSessionMcpServers.delete(id);
2354
2523
  this.#knownSessionMetadata.delete(id);
2524
+ this.#retiredPromptCorrelations.delete(id);
2525
+ this.#retiredPromptAcknowledgements.delete(id);
2355
2526
  }
2356
2527
 
2357
2528
  async #closeOwnedSession(id: string): Promise<CloseSessionResponse> {
@@ -2366,6 +2537,8 @@ export class AcpAgent implements Agent {
2366
2537
  this.#ownedSessionIds.delete(id);
2367
2538
  this.#knownSessionMcpServers.delete(id);
2368
2539
  this.#knownSessionMetadata.delete(id);
2540
+ this.#retiredPromptCorrelations.delete(id);
2541
+ this.#retiredPromptAcknowledgements.delete(id);
2369
2542
  return {};
2370
2543
  } finally {
2371
2544
  this.#finishTeardown(id);
@@ -2384,10 +2557,14 @@ export class AcpAgent implements Agent {
2384
2557
  try {
2385
2558
  this.#advanceSessionEpoch(id);
2386
2559
  if (record) {
2560
+ const waiter = record.activePrompt;
2561
+ if (waiter) {
2562
+ this.#rememberSettledPromptCorrelation(id, record, waiter.correlation);
2563
+ this.#fenceRetiredPromptAcknowledgement(id, waiter);
2564
+ }
2387
2565
  this.#sessions.delete(id);
2388
2566
  record.unsubscribe();
2389
2567
  record.reconnectUnsubscribe();
2390
- const waiter = record.activePrompt;
2391
2568
  record.activePrompt = undefined;
2392
2569
  // `session/close` is the client asking to end its own work, so the pending turn
2393
2570
  // settles as `cancelled` rather than surfacing a spurious error. ACP: "Agents
@@ -2447,7 +2624,11 @@ export class AcpAgent implements Agent {
2447
2624
  record.reconnectUnsubscribe();
2448
2625
  const waiter = record.activePrompt;
2449
2626
  record.activePrompt = undefined;
2450
- if (waiter) clearPromptWatchdog(waiter);
2627
+ if (waiter) {
2628
+ clearPromptWatchdog(waiter);
2629
+ this.#rememberSettledPromptCorrelation(id, record, waiter.correlation);
2630
+ this.#fenceRetiredPromptAcknowledgement(id, waiter);
2631
+ }
2451
2632
  waiter?.reject(error);
2452
2633
  try {
2453
2634
  await adapter.close();
@@ -2516,13 +2697,53 @@ export class AcpAgent implements Agent {
2516
2697
 
2517
2698
  #observeSessionActivity(record: SessionRecord, frame: JsonObject): void {
2518
2699
  if (frame.type === "activity") {
2519
- if (frame.state === "busy") record.busy = true;
2520
- 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
+ }
2521
2717
  return;
2522
2718
  }
2523
2719
  const event = receivedSdkEvent(frame)?.event;
2524
- if (event?.type === "agent_start") record.busy = true;
2525
- 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
+ }
2526
2747
  }
2527
2748
 
2528
2749
  #frameProcessingFailure(error: unknown): AcpSdkAdapterError {
@@ -2559,7 +2780,7 @@ export class AcpAgent implements Agent {
2559
2780
  */
2560
2781
  #refreshPromptWatchdog(id: string, record: SessionRecord, frame: JsonObject): void {
2561
2782
  const waiter = record.activePrompt;
2562
- if (!waiter || waiter.settled) return;
2783
+ if (!waiter || waiter.settled || waiter.terminalReserved) return;
2563
2784
  const event = receivedSdkEvent(frame)?.event;
2564
2785
  const correlation = watchdogCorrelationFrom(frame, event);
2565
2786
  if (!waiter.acknowledged) {
@@ -2595,7 +2816,7 @@ export class AcpAgent implements Agent {
2595
2816
  * turn can no longer be observed, it does not cancel or tear down the session.
2596
2817
  */
2597
2818
  async #expirePromptWatchdog(id: string, record: SessionRecord, waiter: PromptWaiter): Promise<void> {
2598
- if (record.activePrompt !== waiter || waiter.settled) return;
2819
+ if (record.activePrompt !== waiter || waiter.settled || waiter.terminalReserved) return;
2599
2820
  const silenceMs = Math.max(0, this.#promptWatchdogClock.now() - waiter.lastFrameAt);
2600
2821
  const cause = this.#promptTransportGone(id, record) ?? "the SDK session host stopped producing frames";
2601
2822
  logger.error("acp_prompt_watchdog_expired", {
@@ -2625,20 +2846,48 @@ export class AcpAgent implements Agent {
2625
2846
  #enqueueSdkFrame(id: string, adapter: AcpSdkAdapter, frame: JsonObject): void {
2626
2847
  const record = this.#sessions.get(id);
2627
2848
  if (!record || record.adapter !== adapter) return;
2628
- 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;
2629
2851
  // Ingress ordering is recorded before queued work begins.
2630
2852
  this.#observeSessionActivity(record, frame);
2631
2853
  // Correlation is checked at ingress before a prompt-owned frame may refresh the
2632
2854
  // watchdog, so queued processing cannot turn unrelated host traffic into turn liveness.
2633
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
+ }
2634
2875
  ++record.inboundSequence;
2635
- const task = record.frameTail.then(async () => await this.#handleSdkFrame(id, adapter, frame));
2636
- record.frameTail = task.catch(
2637
- async error => await this.#failSession(id, adapter, this.#frameProcessingFailure(error)),
2638
- );
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
+ });
2639
2883
  }
2640
2884
 
2641
- 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> {
2642
2891
  const record = this.#sessions.get(id);
2643
2892
  if (!record || record.adapter !== adapter) return;
2644
2893
  if ((frame.type === "hello" || frame.type === "server_hello") && typeof frame.connectionId === "string") {
@@ -2648,19 +2897,18 @@ export class AcpAgent implements Agent {
2648
2897
  const waiter = record.activePrompt;
2649
2898
  if (waiter && !waiter.settled && !waiter.terminal) {
2650
2899
  record.activePrompt = undefined;
2900
+ record.busy = record.backgroundBusy;
2651
2901
  clearPromptWatchdog(waiter);
2652
2902
  waiter.settled = true;
2653
- if (hasCorrelation(waiter.correlation)) {
2654
- record.settledPromptCorrelations.push(waiter.correlation);
2655
- while (record.settledPromptCorrelations.length > SETTLED_PROMPT_CORRELATION_RETENTION)
2656
- record.settledPromptCorrelations.shift();
2657
- }
2903
+ this.#rememberSettledPromptCorrelation(id, record, waiter.correlation);
2904
+ this.#fenceRetiredPromptAcknowledgement(id, waiter);
2658
2905
  waiter.reject(
2659
2906
  new AcpSdkAdapterError(
2660
2907
  "connection_closed",
2661
2908
  "The prompt owner connection was lost before completion.",
2662
2909
  ),
2663
2910
  );
2911
+ this.#flushFailureDiagnostics(id, waiter, record.adapter);
2664
2912
  // The turn is settled by the rejection; release the running phase
2665
2913
  // best-effort so a cancelled prompt is not left reporting working.
2666
2914
  void this.#publishPromptPhaseIdle(id, record.adapter);
@@ -2671,7 +2919,9 @@ export class AcpAgent implements Agent {
2671
2919
  const received = receivedSdkEvent(frame);
2672
2920
  if (!received) return;
2673
2921
  const { event, wirePayload } = received;
2674
- 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");
2675
2925
  if (event.type === "notice" && event.source === "autorouting" && typeof event.message === "string") {
2676
2926
  record.routingInactiveNotice = event.message;
2677
2927
  return;
@@ -2679,21 +2929,58 @@ export class AcpAgent implements Agent {
2679
2929
  const derivedCorrelation = sdkFrameCorrelation(frame, event);
2680
2930
  const correlation = derivedCorrelation ?? {};
2681
2931
  const activePrompt = record.activePrompt;
2682
- const outcome = isTerminal ? terminalOutcome(event) : undefined;
2932
+ let terminalPromptOwner: PromptWaiter | undefined;
2683
2933
  const settledCorrelation = record.settledPromptCorrelations.some(settled =>
2684
2934
  correlationsMatch(settled, correlation),
2685
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);
2686
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
+ }
2687
2974
  // Terminal ownership requires a complete identity. Unowned, partial, and
2688
2975
  // duplicate terminals are never allowed to publish or query anything.
2689
- if (!activePrompt || activePrompt.settled) return;
2976
+ if (activePrompt.settled) return;
2690
2977
  if (!hasCompleteCorrelation(correlation)) {
2691
2978
  logDroppedPromptTerminal(id, event, "incomplete_correlation", correlation, activePrompt.correlation);
2692
2979
  return;
2693
2980
  }
2694
2981
  if (!activePrompt.acknowledged) {
2695
2982
  // Hold the entire frame until the prompt acknowledgement proves ownership.
2696
- activePrompt.deferredFrames.push(frame);
2983
+ activePrompt.deferredFrames.push({ frame, publicationGeneration });
2697
2984
  return;
2698
2985
  }
2699
2986
  const matchesPrompt = correlationsExactlyMatch(activePrompt.correlation, correlation);
@@ -2703,26 +2990,30 @@ export class AcpAgent implements Agent {
2703
2990
  return;
2704
2991
  }
2705
2992
  if (activePrompt.terminal) return;
2993
+ record.busy = record.backgroundBusy;
2994
+ terminalPromptOwner = activePrompt;
2995
+ this.#advanceTerminalGeneration(record);
2996
+ publicationGeneration = record.publicationGeneration;
2706
2997
  if (!outcome) {
2707
- const detail =
2708
- typeof (event as { error?: { message?: unknown } }).error?.message === "string"
2709
- ? (event as { error: { message: string } }).error.message
2710
- : "the prompt terminal omitted a valid normalized outcome";
2711
2998
  await this.#rejectPrompt(
2712
2999
  record,
2713
3000
  id,
2714
3001
  activePrompt,
2715
- new AcpSdkAdapterError("connection_closed", `ACP prompt terminal was invalid: ${detail}`),
3002
+ new AcpSdkAdapterError("connection_closed", "ACP prompt terminal was invalid or incomplete."),
2716
3003
  );
2717
3004
  return;
2718
3005
  }
2719
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);
2720
3011
  }
2721
3012
  if (!isTerminal && derivedCorrelation === undefined) return;
2722
3013
  if (!isTerminal && hasCorrelation(correlation)) {
2723
3014
  if (!activePrompt || activePrompt.settled) return;
2724
3015
  if (!activePrompt.acknowledged) {
2725
- activePrompt.deferredFrames.push(frame);
3016
+ activePrompt.deferredFrames.push({ frame, publicationGeneration });
2726
3017
  return;
2727
3018
  }
2728
3019
  if (!correlationsExactlyMatch(activePrompt.correlation, correlation)) return;
@@ -2731,18 +3022,19 @@ export class AcpAgent implements Agent {
2731
3022
  // Frames for an already-settled correlation stay closed until an active prompt
2732
3023
  // acknowledges the exact same identity.
2733
3024
  if (activePrompt && !activePrompt.settled && !activePrompt.acknowledged) {
2734
- activePrompt.deferredFrames.push(frame);
3025
+ activePrompt.deferredFrames.push({ frame, publicationGeneration });
2735
3026
  return;
2736
3027
  }
2737
3028
  if (!activePrompt || activePrompt.settled || !correlationsMatch(activePrompt.correlation, correlation)) return;
2738
3029
  }
2739
3030
  const promptOwner =
2740
- activePrompt &&
3031
+ terminalPromptOwner ??
3032
+ (activePrompt &&
2741
3033
  !activePrompt.settled &&
2742
3034
  activePrompt.acknowledged &&
2743
3035
  correlationsExactlyMatch(activePrompt.correlation, correlation)
2744
3036
  ? activePrompt
2745
- : undefined;
3037
+ : undefined);
2746
3038
  const toolCallId = typeof event.toolCallId === "string" ? event.toolCallId : undefined;
2747
3039
  if (
2748
3040
  toolCallId &&
@@ -2765,7 +3057,9 @@ export class AcpAgent implements Agent {
2765
3057
  },
2766
3058
  };
2767
3059
  const notifications = wirePayload
2768
- ? mapAgentWireEventPayloadToAcpSessionUpdates(wirePayload as never, id, mapperOptions)
3060
+ ? event.type === "agent_failed" && !object(event.error)
3061
+ ? []
3062
+ : mapAgentWireEventPayloadToAcpSessionUpdates(wirePayload as never, id, mapperOptions)
2769
3063
  : event.type === "agent_failed" && object(event.error)
2770
3064
  ? mapAgentSessionEventToAcpSessionUpdates(event as unknown as AgentSessionEvent, id, mapperOptions)
2771
3065
  : [];
@@ -2776,7 +3070,21 @@ export class AcpAgent implements Agent {
2776
3070
  notification.update.content.type === "text"
2777
3071
  )
2778
3072
  promptOwner.emittedAssistantText += notification.update.content.text;
2779
- 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
+ );
2780
3088
  }
2781
3089
  if (toolCallId && event.type === "tool_execution_end") record.toolArgs.delete(toolCallId);
2782
3090
  if (event.type === "agent_start") {
@@ -2791,15 +3099,64 @@ export class AcpAgent implements Agent {
2791
3099
  },
2792
3100
  },
2793
3101
  adapter,
3102
+ promptOwner ? publicationGeneration : undefined,
2794
3103
  );
2795
3104
  }
2796
3105
  if (event.type === "message_end" && object(event.message)?.role === "assistant") {
2797
3106
  if (promptOwner) promptOwner.messageProgress = undefined;
2798
3107
  else record.sessionMessageProgress = undefined;
2799
3108
  }
2800
- if (event.type === "agent_end") {
2801
- const finalText = typeof event.finalText === "string" ? event.finalText : "";
2802
- 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);
2803
3160
  const resolution = resolveAcpFinalText(promptOwner.emittedAssistantText, finalText);
2804
3161
  if (resolution.kind === "emit") {
2805
3162
  promptOwner.emittedAssistantText += resolution.text;
@@ -2814,6 +3171,7 @@ export class AcpAgent implements Agent {
2814
3171
  },
2815
3172
  },
2816
3173
  adapter,
3174
+ publicationGeneration,
2817
3175
  );
2818
3176
  } else if (resolution.kind === "divergent") {
2819
3177
  logger.warn("acp_final_text_diverged", {
@@ -2824,19 +3182,27 @@ export class AcpAgent implements Agent {
2824
3182
  finalLength: resolution.final.text.length,
2825
3183
  });
2826
3184
  }
2827
- }
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;
2828
3196
  }
2829
- // The terminal frame is the turn's end, so it settles the prompt before anything
2830
- // else is asked of the session host. `#emitEndOfTurnUpdates` queries `context.get`
2831
- // and `session.metadata`, and a host that stops producing the moment it publishes
2832
- // its terminal answers neither: sequencing settlement behind those advisory
2833
- // queries is what left a finished turn reported as running until the inactivity
2834
- // watchdog rescued it.
2835
- if (activePrompt) this.#settlePrompt(record, activePrompt);
2836
- // agent_failed is correlated diagnostic state while the run is still live.
2837
- // Only the authoritative agent_end boundary may publish end-of-turn usage,
2838
- // title, and idle integration updates.
2839
- if (event.type === "agent_end") await this.#emitEndOfTurnUpdates(id, adapter);
3197
+ if (failedTerminal) {
3198
+ void decorationStart.then(async () => await this.#publishPromptPhaseIdle(id, adapter));
3199
+ return;
3200
+ }
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
+ });
2840
3206
  }
2841
3207
 
2842
3208
  async #rejectPrompt(
@@ -2844,26 +3210,26 @@ export class AcpAgent implements Agent {
2844
3210
  id: string,
2845
3211
  waiter: PromptWaiter,
2846
3212
  error: AcpSdkAdapterError,
3213
+ publishIdle = true,
2847
3214
  ): Promise<void> {
2848
3215
  if (record.activePrompt !== waiter || waiter.settled) return;
2849
3216
  record.activePrompt = undefined;
3217
+ if (!waiter.acknowledged) record.busy = record.backgroundBusy;
2850
3218
  clearPromptWatchdog(waiter);
2851
3219
  waiter.settled = true;
3220
+ this.#fenceRetiredPromptAcknowledgement(id, waiter);
2852
3221
  waiter.deferredFrames.length = 0;
2853
3222
  waiter.deferredActivityFrames.length = 0;
2854
3223
  waiter.terminal = undefined;
2855
- if (hasCompleteCorrelation(waiter.correlation)) {
2856
- record.settledPromptCorrelations.push(waiter.correlation);
2857
- while (record.settledPromptCorrelations.length > SETTLED_PROMPT_CORRELATION_RETENTION)
2858
- record.settledPromptCorrelations.shift();
2859
- }
3224
+ this.#rememberSettledPromptCorrelation(id, record, waiter.correlation);
2860
3225
  // The turn is over even though it ended badly, so the client's running phase has
2861
3226
  // to be released. Rejection is the settlement; the advisory idle publication must
2862
3227
  // never gate it (a backpressured transport would otherwise hold the rejection
2863
3228
  // forever). Skipping the idle publish entirely is what left a client composer
2864
3229
  // spinning on a turn that already produced its terminal.
2865
3230
  waiter.reject(error);
2866
- void this.#publishPromptPhaseIdle(id, record.adapter);
3231
+ this.#flushFailureDiagnostics(id, waiter, record.adapter);
3232
+ if (publishIdle) void this.#publishPromptPhaseIdle(id, record.adapter);
2867
3233
  }
2868
3234
 
2869
3235
  /**
@@ -2878,20 +3244,62 @@ export class AcpAgent implements Agent {
2878
3244
  async #publishPromptPhaseIdle(id: string, adapter: AcpSdkAdapter): Promise<void> {
2879
3245
  const record = this.#sessions.get(id);
2880
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> {
2881
3272
  try {
2882
- await this.#connection.sessionUpdate({
2883
- sessionId: id,
2884
- update: {
2885
- sessionUpdate: "session_info_update",
2886
- _meta: { gjcPhase: "idle", running: false, gjcRunning: false },
2887
- },
2888
- });
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
+ }
2889
3297
  } catch {
2890
3298
  // The client transport is gone; there is no phase left to restore.
2891
3299
  }
2892
3300
  }
2893
3301
 
2894
- #settlePrompt(record: SessionRecord, waiter: PromptWaiter): void {
3302
+ #settlePrompt(id: string, record: SessionRecord, waiter: PromptWaiter): void {
2895
3303
  if (record.activePrompt !== waiter || waiter.settled || !waiter.acknowledged || !waiter.terminal) return;
2896
3304
  // A terminal captured before acknowledgement is only this prompt's terminal when the
2897
3305
  // eventual acknowledgement correlates with it; otherwise it belonged to an earlier prompt.
@@ -2902,12 +3310,8 @@ export class AcpAgent implements Agent {
2902
3310
  record.activePrompt = undefined;
2903
3311
  clearPromptWatchdog(waiter);
2904
3312
  waiter.settled = true;
2905
- if (hasCorrelation(waiter.correlation)) {
2906
- record.settledPromptCorrelations.push(waiter.correlation);
2907
- while (record.settledPromptCorrelations.length > SETTLED_PROMPT_CORRELATION_RETENTION)
2908
- record.settledPromptCorrelations.shift();
2909
- }
2910
3313
  const { outcome } = waiter.terminal;
3314
+ this.#rememberSettledPromptCorrelation(id, record, waiter.correlation);
2911
3315
  if (outcome.kind === "stopped") {
2912
3316
  waiter.resolve({ stopReason: outcome.reason });
2913
3317
  return;
@@ -2915,7 +3319,7 @@ export class AcpAgent implements Agent {
2915
3319
  waiter.reject(new AcpSdkAdapterError(outcome.code, outcome.message));
2916
3320
  }
2917
3321
 
2918
- async #emitEndOfTurnUpdates(id: string, adapter: AcpSdkAdapter): Promise<void> {
3322
+ async #emitEndOfTurnUpdates(id: string, adapter: AcpSdkAdapter, publicationGeneration: number): Promise<void> {
2919
3323
  let usage: JsonObject | undefined;
2920
3324
  let title: string | undefined;
2921
3325
  try {
@@ -2936,42 +3340,70 @@ export class AcpAgent implements Agent {
2936
3340
  }
2937
3341
  // The prompt settled before these queries were asked, so a host that answers late
2938
3342
  // must not report the session idle after the next turn has already started.
2939
- if (this.#sessions.get(id)?.activePrompt) return;
2940
- 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 });
2941
3376
  await this.#publishSessionUpdate(
2942
3377
  id,
2943
3378
  {
2944
3379
  sessionId: id,
2945
3380
  update: {
2946
- sessionUpdate: "usage_update",
2947
- size: usage.contextWindow,
2948
- used: usage.tokens,
3381
+ sessionUpdate: "session_info_update",
3382
+ ...(title ? { title } : {}),
3383
+ updatedAt,
3384
+ _meta: { gjcPhase: "idle", running: false, gjcRunning: false },
2949
3385
  },
2950
3386
  },
2951
3387
  adapter,
3388
+ publicationGeneration,
2952
3389
  );
2953
- }
2954
- const updatedAt = new Date().toISOString();
2955
- this.#knownSessionMetadata.set(id, { ...(title ? { title } : {}), updatedAt });
2956
- await this.#publishSessionUpdate(
2957
- id,
2958
- {
2959
- sessionId: id,
2960
- update: {
2961
- sessionUpdate: "session_info_update",
2962
- ...(title ? { title } : {}),
2963
- updatedAt,
2964
- _meta: { gjcPhase: "idle", running: false, gjcRunning: false },
2965
- },
2966
- },
2967
- adapter,
2968
- );
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;
2969
3400
  }
2970
3401
 
2971
3402
  async #publishSessionUpdate(
2972
3403
  id: string,
2973
3404
  notification: SessionNotification,
2974
3405
  expectedAdapter?: AcpSdkAdapter,
3406
+ publicationGeneration?: number,
2975
3407
  ): Promise<void> {
2976
3408
  // A session that is gone has no update channel, so this is a drop and not a failure:
2977
3409
  // whoever owned the frame is the one that ended the session. Callers whose bookkeeping
@@ -2980,7 +3412,27 @@ export class AcpAgent implements Agent {
2980
3412
  if (!record || (expectedAdapter && record.adapter !== expectedAdapter)) return;
2981
3413
  try {
2982
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
+ }
2983
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;
2984
3436
  const failure = this.#frameProcessingFailure(error);
2985
3437
  await this.#failSession(id, record.adapter, failure);
2986
3438
  throw failure;
@@ -3516,17 +3968,8 @@ export class AcpAgent implements Agent {
3516
3968
  record.adapter,
3517
3969
  );
3518
3970
  }
3519
- await this.#publishSessionUpdate(
3520
- id,
3521
- {
3522
- sessionId: id,
3523
- update: {
3524
- sessionUpdate: "session_info_update",
3525
- _meta: { gjcPhase: "idle", running: false, gjcRunning: false },
3526
- },
3527
- },
3528
- record.adapter,
3529
- );
3971
+ const current = this.#sessions.get(id);
3972
+ if (current) await this.#publishPromptPhase(id, current.adapter, this.#promptPhaseOwner(current));
3530
3973
  })().catch(() => undefined);
3531
3974
  });
3532
3975
  }
@@ -3685,6 +4128,12 @@ export class AcpAgent implements Agent {
3685
4128
  this.#ownedSessionIds.clear();
3686
4129
  this.#knownSessionMcpServers.clear();
3687
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();
3688
4137
  this.#pendingDeleteLocators.clear();
3689
4138
  this.#pendingCloseIdempotencyKeys.clear();
3690
4139
  if (this.#lifecycleOperations.size === 0) this.#lifecycleOperations.clear();