@gajae-code/coding-agent 0.18.5 → 0.18.6

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 (91) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/dist/types/cli-commands.d.ts +10 -0
  3. package/dist/types/cli-ordinary.d.ts +13 -0
  4. package/dist/types/cli.d.ts +12 -3
  5. package/dist/types/commands/contribution-prep.d.ts +0 -3
  6. package/dist/types/commands/sdk.d.ts +3 -0
  7. package/dist/types/commands/update.d.ts +1 -0
  8. package/dist/types/config/model-profile-activation.d.ts +19 -0
  9. package/dist/types/coordinator-mcp/session-reaper.d.ts +5 -0
  10. package/dist/types/edit/modes/replace.d.ts +3 -3
  11. package/dist/types/edit/streaming.d.ts +2 -2
  12. package/dist/types/exec/bash-shell-supervisor.d.ts +12 -0
  13. package/dist/types/sdk/broker/discovery.d.ts +8 -0
  14. package/dist/types/sdk/broker/ensure.d.ts +1 -0
  15. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +4 -0
  16. package/dist/types/sdk/broker/runtime.d.ts +1 -1
  17. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  18. package/dist/types/sdk/bus/telegram-daemon.d.ts +1 -0
  19. package/dist/types/sdk/cli/diagnostics-cli.d.ts +18 -0
  20. package/dist/types/sdk/diagnostics/observe-broker.d.ts +185 -0
  21. package/dist/types/sdk/host/control/operations.d.ts +1 -1
  22. package/dist/types/sdk/index.d.ts +1 -0
  23. package/dist/types/sdk/router/session-router.d.ts +2 -0
  24. package/dist/types/sdk/session.d.ts +13 -0
  25. package/dist/types/session/agent-session.d.ts +5 -1
  26. package/dist/types/tools/ask-contract.d.ts +75 -254
  27. package/dist/types/tools/bash.d.ts +1 -1
  28. package/package.json +7 -7
  29. package/scripts/build-sdk-package-smoke.ts +5 -0
  30. package/src/cli/public-command-entry.ts +5 -0
  31. package/src/cli/ssh-cli.ts +6 -3
  32. package/src/cli/web-search-cli.ts +9 -3
  33. package/src/cli-commands.ts +14 -0
  34. package/src/cli-ordinary.ts +197 -0
  35. package/src/cli.ts +25 -182
  36. package/src/commands/contribution-prep.ts +1 -3
  37. package/src/commands/sdk.ts +59 -0
  38. package/src/commands/update.ts +15 -1
  39. package/src/config/file-lock.ts +91 -2
  40. package/src/config/model-profile-activation.ts +70 -4
  41. package/src/config/model-profile-contract.ts +4 -1
  42. package/src/config/model-profiles.ts +23 -31
  43. package/src/config/model-resolver.ts +9 -1
  44. package/src/coordinator-mcp/event-webhook.ts +19 -14
  45. package/src/coordinator-mcp/policy.ts +14 -4
  46. package/src/coordinator-mcp/server.ts +142 -7
  47. package/src/coordinator-mcp/session-reaper.ts +9 -1
  48. package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
  49. package/src/edit/modes/replace.ts +6 -6
  50. package/src/edit/streaming.ts +15 -15
  51. package/src/exec/bash-shell-supervisor.ts +60 -3
  52. package/src/internal-urls/docs-index.generated.ts +10 -9
  53. package/src/memories/index.ts +8 -2
  54. package/src/modes/acp/acp-agent.ts +649 -367
  55. package/src/modes/components/tool-execution.ts +1 -1
  56. package/src/prompts/tools/ask.md +2 -0
  57. package/src/sdk/broker/broker.ts +46 -0
  58. package/src/sdk/broker/daemon-entry.ts +67 -6
  59. package/src/sdk/broker/discovery.ts +8 -0
  60. package/src/sdk/broker/ensure.ts +156 -20
  61. package/src/sdk/broker/lifecycle-ledger.ts +110 -12
  62. package/src/sdk/broker/lifecycle.ts +225 -341
  63. package/src/sdk/broker/runtime.ts +5 -1
  64. package/src/sdk/broker/transport.ts +5 -1
  65. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  66. package/src/sdk/bus/telegram-daemon-control.ts +15 -12
  67. package/src/sdk/bus/telegram-daemon.ts +34 -1
  68. package/src/sdk/cli/diagnostics-cli.ts +247 -0
  69. package/src/sdk/diagnostics/observe-broker.ts +654 -0
  70. package/src/sdk/host/control/dispatch.ts +52 -4
  71. package/src/sdk/host/control/operations.ts +1 -1
  72. package/src/sdk/host/session-runtime.test.ts +40 -2
  73. package/src/sdk/host/session-runtime.ts +13 -5
  74. package/src/sdk/index.ts +1 -0
  75. package/src/sdk/lifecycle-session.ts +1 -0
  76. package/src/sdk/protocol/operation-registry.ts +1 -1
  77. package/src/sdk/router/session-router.ts +7 -1
  78. package/src/sdk/session.ts +37 -8
  79. package/src/session/agent-session.ts +114 -39
  80. package/src/session/blob-store.ts +78 -33
  81. package/src/session/internal/managed-session-storage.ts +96 -3
  82. package/src/setup/hermes/templates/operator-instructions.v1.md +19 -1
  83. package/src/setup/hermes-setup.ts +4 -1
  84. package/src/slash-commands/builtin-registry.ts +8 -5
  85. package/src/tools/ask-contract.test.ts +76 -1
  86. package/src/tools/ask-contract.ts +57 -6
  87. package/src/tools/ask.ts +16 -7
  88. package/src/tools/bash.ts +38 -29
  89. package/src/tools/descriptors.test.ts +29 -0
  90. package/src/tools/tool-catalog.generated.ts +7 -4
  91. package/src/web/search/index.ts +2 -2
@@ -162,6 +162,13 @@ export const ACP_SESSION_READINESS_TIMEOUT_MS = 22_000;
162
162
  const ACP_FIRST_PROMPT_MAX_RETRIES = 2;
163
163
  /** Backoff before each first-prompt retry, scaled by attempt (250ms, then 500ms). */
164
164
  const ACP_FIRST_PROMPT_RETRY_BASE_DELAY_MS = 250;
165
+ const ACP_BUSY_SETTLE_WAIT_MS = 5_000;
166
+ /**
167
+ * Default model settlement timeout: when no --model is passed but modelRoles.default is configured,
168
+ * we wait for the SDK to apply the default before answering session/new, so the model option is present.
169
+ * The SDK applies defaults synchronously for known models, so this is a bounded safety timeout.
170
+ */
171
+ const ACP_MODEL_SETTLEMENT_TIMEOUT_MS = 500;
165
172
 
166
173
  type JsonObject = Record<string, unknown>;
167
174
  interface PromptWaiter {
@@ -230,6 +237,8 @@ interface PromptWaiter {
230
237
  cancelAttemptResolve?: (acknowledged: boolean) => void;
231
238
  resolve: (response: PromptResponse) => void;
232
239
  reject: (error: Error) => void;
240
+ /** Resolves when the prompt response settles, regardless of success or failure. */
241
+ settlement: Promise<void>;
233
242
  }
234
243
 
235
244
  type PromptCorrelation = { commandId?: string; turnId?: string };
@@ -247,6 +256,14 @@ type PromptPhaseOwner = PromptWaiter | "background" | undefined;
247
256
  type BrokerConnection = { adapter: AcpSdkAdapter; client: SdkClient };
248
257
  type PendingAttachment = { epoch: number; task: Promise<void> };
249
258
 
259
+ type PromptAdmissionSettlement = { kind: "cancelled" } | { kind: "rejected"; error: AcpSdkAdapterError };
260
+ type PromptAdmissionReservation = {
261
+ cancelled?: boolean;
262
+ settlement?: PromptAdmissionSettlement;
263
+ wake: () => void;
264
+ woken: Promise<void>;
265
+ };
266
+
250
267
  type SessionRecord = {
251
268
  cwd: string;
252
269
  adapter: AcpSdkAdapter;
@@ -263,9 +280,12 @@ type SessionRecord = {
263
280
  inboundSequence: number;
264
281
  /** Updated at ingress so a prompt acknowledgement can distinguish a steer from a fresh turn. */
265
282
  busy: boolean;
283
+ /** Last SDK activity state; ACP prompt settlement can precede the SDK's idle publication. */
284
+ sdkIdle: boolean;
266
285
  backgroundBusy: boolean;
267
286
  backgroundAnonymousCount: number;
268
287
  backgroundCorrelations: PromptCorrelation[];
288
+ idleWaiters: Set<() => void>;
269
289
  /** Start/update args retained because tool_execution_end does not carry them. */
270
290
  toolArgs: Map<string, unknown>;
271
291
  /** Message projection state for correlationless session-scoped assistant events. */
@@ -295,6 +315,8 @@ type SessionRecord = {
295
315
  promptObservedAssistantOutput?: boolean;
296
316
  /** Owner token held across the first-turn retry backoff; a non-owner prompt is rejected as a conflict (review P1). */
297
317
  pendingFirstPromptRetry?: FirstPromptRetryReservation;
318
+ /** Sole successor admitted while a cancelled prompt settles and publication tails drain. */
319
+ pendingPromptAdmission?: PromptAdmissionReservation;
298
320
  };
299
321
 
300
322
  /**
@@ -1749,6 +1771,10 @@ export class AcpAgent implements Agent {
1749
1771
  this.#agentDir = typeof candidate?.agentDir === "string" ? candidate.agentDir : getAgentDir();
1750
1772
  this.#router = new SessionRouter({
1751
1773
  agentDir: this.#agentDir,
1774
+ attachFilter: sessionId =>
1775
+ this.#sessions.has(sessionId) ||
1776
+ this.#pendingRouterAdapters.has(sessionId) ||
1777
+ this.#pendingRouterFrames.has(sessionId),
1752
1778
  deps: {
1753
1779
  onAttachment: attachment => {
1754
1780
  const record = this.#sessions.get(attachment.sessionId);
@@ -1848,33 +1874,62 @@ export class AcpAgent implements Agent {
1848
1874
  const mcpServers = this.#mcpServers(params);
1849
1875
  this.#assertAbsoluteCwd(params.cwd);
1850
1876
  this.#assertNoAdditionalDirectories(params.additionalDirectories);
1851
- const result = await this.#launchSessionWithMcp(
1852
- "session.create",
1853
- {
1854
- cwd: params.cwd,
1855
- target: { path: params.cwd },
1856
- ...(this.#startupOptions?.modelPreset ? { modelPreset: this.#startupOptions.modelPreset } : {}),
1857
- readinessTimeoutMs: ACP_SESSION_READINESS_TIMEOUT_MS,
1858
- ...(mcpServers.length > 0 ? { mcpServers } : {}),
1859
- },
1860
- randomUUID(),
1861
- mcpServers,
1862
- );
1863
- const id = sessionId(result);
1864
- this.#knownSessionCwds.set(id, params.cwd);
1865
- this.#knownSessionMcpServers.set(id, mcpServers);
1866
- // This connection launched the host, so it owns the broker lifecycle before the
1867
- // attachment reports a control surface. Without it a failed attach discards the
1868
- // session as unowned and masks the real error with cleanup uncertainty.
1869
- this.#ownedSessionIds.add(id);
1877
+ const startedAt = performance.now();
1878
+ const phases: Record<string, number> = {};
1879
+ let id: string | undefined;
1870
1880
  try {
1881
+ let phaseStartedAt = performance.now();
1882
+ const result = await this.#launchSessionWithMcp(
1883
+ "session.create",
1884
+ {
1885
+ cwd: params.cwd,
1886
+ target: { path: params.cwd },
1887
+ ...(this.#startupOptions?.modelPreset ? { modelPreset: this.#startupOptions.modelPreset } : {}),
1888
+ readinessTimeoutMs: ACP_SESSION_READINESS_TIMEOUT_MS,
1889
+ ...(mcpServers.length > 0 ? { mcpServers } : {}),
1890
+ },
1891
+ randomUUID(),
1892
+ mcpServers,
1893
+ );
1894
+ phases.launchMs = performance.now() - phaseStartedAt;
1895
+ id = sessionId(result);
1896
+ this.#knownSessionCwds.set(id, params.cwd);
1897
+ this.#knownSessionMcpServers.set(id, mcpServers);
1898
+ // This connection launched the host, so it owns the broker lifecycle before the
1899
+ // attachment reports a control surface. Without it a failed attach discards the
1900
+ // session as unowned and masks the real error with cleanup uncertainty.
1901
+ this.#ownedSessionIds.add(id);
1902
+ phaseStartedAt = performance.now();
1871
1903
  await this.#attach(id, params.cwd, undefined, result);
1904
+ phases.attachMs = performance.now() - phaseStartedAt;
1905
+ phaseStartedAt = performance.now();
1872
1906
  await applyAcpStartupOptions(this.#adapter(id), this.#startupOptions);
1907
+ phases.startupOptionsMs = performance.now() - phaseStartedAt;
1908
+ phaseStartedAt = performance.now();
1909
+ await this.#waitForModelSettle(id);
1910
+ phases.modelSettleMs = performance.now() - phaseStartedAt;
1911
+ phaseStartedAt = performance.now();
1873
1912
  const response = { sessionId: id, ...(await this.#sessionState(id, true)) };
1913
+ phases.sessionStateMs = performance.now() - phaseStartedAt;
1874
1914
  this.#scheduleBootstrap(id);
1915
+ logger.debug("acp_session_new_phases", {
1916
+ sessionId: id,
1917
+ ...phases,
1918
+ outcome: "ok",
1919
+ totalMs: performance.now() - startedAt,
1920
+ });
1875
1921
  return response;
1876
1922
  } catch (error) {
1877
- await this.#discardNewSession(id);
1923
+ try {
1924
+ if (id !== undefined) await this.#discardNewSession(id);
1925
+ } finally {
1926
+ logger.debug("acp_session_new_phases", {
1927
+ ...(id !== undefined ? { sessionId: id } : {}),
1928
+ ...phases,
1929
+ outcome: "error",
1930
+ totalMs: performance.now() - startedAt,
1931
+ });
1932
+ }
1878
1933
  throw error;
1879
1934
  }
1880
1935
  }
@@ -2277,6 +2332,70 @@ export class AcpAgent implements Agent {
2277
2332
  ): Promise<PromptResponse> {
2278
2333
  const record = this.#sessions.get(params.sessionId);
2279
2334
  if (!record) throw new AcpSdkAdapterError("not_found", `Unknown session, not found: ${params.sessionId}`);
2335
+ let admissionReservation: PromptAdmissionReservation | undefined;
2336
+ if (record.pendingPromptAdmission)
2337
+ throw new AcpSdkAdapterError("conflict", "ACP session already has an active prompt.");
2338
+ const activePrompt = record.activePrompt;
2339
+ if (activePrompt) {
2340
+ const cancellationPending =
2341
+ record.cancelRequested ||
2342
+ activePrompt.cancelAttempt !== undefined ||
2343
+ activePrompt.cancelAcknowledged === true;
2344
+ if (!cancellationPending || record.pendingPromptAdmission)
2345
+ throw new AcpSdkAdapterError("conflict", "ACP session already has an active prompt.");
2346
+ const { promise: woken, resolve: wake } = Promise.withResolvers<void>();
2347
+ admissionReservation = { wake, woken };
2348
+ record.pendingPromptAdmission = admissionReservation;
2349
+ }
2350
+ try {
2351
+ if (admissionReservation) {
2352
+ const settled = await this.#waitForPromptSettlement(activePrompt as PromptWaiter, admissionReservation);
2353
+ const settlement = admissionReservation.settlement;
2354
+ if (settlement?.kind === "rejected") throw settlement.error;
2355
+ if (settlement?.kind === "cancelled" || admissionReservation.cancelled) return { stopReason: "cancelled" };
2356
+ if (!settled) throw new AcpSdkAdapterError("conflict", "ACP session already has an active prompt.");
2357
+ await this.#drainPromptPublicationTails(params.sessionId);
2358
+ if (admissionReservation.settlement?.kind === "rejected") throw admissionReservation.settlement.error;
2359
+ if (admissionReservation.settlement?.kind === "cancelled" || admissionReservation.cancelled)
2360
+ return { stopReason: "cancelled" };
2361
+ }
2362
+ return await this.#submitPromptCore(params, echoUserMessage, retryReservation, admissionReservation);
2363
+ } finally {
2364
+ if (record.pendingPromptAdmission === admissionReservation) record.pendingPromptAdmission = undefined;
2365
+ }
2366
+ }
2367
+
2368
+ async #waitForPromptSettlement(
2369
+ waiter: PromptWaiter,
2370
+ admissionReservation: PromptAdmissionReservation,
2371
+ ): Promise<boolean> {
2372
+ let cancelTimer: (() => void) | undefined;
2373
+ const timeout = new Promise<boolean>(resolve => {
2374
+ cancelTimer = this.#promptWatchdogClock.schedule(() => resolve(false), ACP_BUSY_SETTLE_WAIT_MS);
2375
+ });
2376
+ try {
2377
+ return await Promise.race([
2378
+ waiter.settlement.then(() => true),
2379
+ timeout,
2380
+ admissionReservation.woken.then(() => true),
2381
+ ]);
2382
+ } finally {
2383
+ cancelTimer?.();
2384
+ }
2385
+ }
2386
+
2387
+ async #submitPromptCore(
2388
+ params: PromptRequest,
2389
+ echoUserMessage: boolean,
2390
+ retryReservation?: FirstPromptRetryReservation,
2391
+ admissionReservation?: PromptAdmissionReservation,
2392
+ ): Promise<PromptResponse> {
2393
+ const admissionSettlement = admissionReservation?.settlement;
2394
+ if (admissionSettlement?.kind === "rejected") throw admissionSettlement.error;
2395
+ if (admissionSettlement?.kind === "cancelled" || admissionReservation?.cancelled)
2396
+ return { stopReason: "cancelled" };
2397
+ const record = this.#sessions.get(params.sessionId);
2398
+ if (!record) throw new AcpSdkAdapterError("not_found", `Unknown session, not found: ${params.sessionId}`);
2280
2399
  // A first-turn retry holds the session across its backoff gap. Any prompt that is not the
2281
2400
  // retry owner is rejected here, before any state mutation or dispatch, so it cannot steal
2282
2401
  // the first turn while the authorized retry is still pending (review P1, issue #5574).
@@ -2362,6 +2481,11 @@ export class AcpAgent implements Agent {
2362
2481
  record.promptObservedToolExecution = false;
2363
2482
  record.promptObservedAssistantOutput = false;
2364
2483
  const { promise: response, resolve, reject } = Promise.withResolvers<PromptResponse>();
2484
+ const { promise: settlement, resolve: completeOwnership } = Promise.withResolvers<void>();
2485
+ void response.then(
2486
+ () => completeOwnership(),
2487
+ () => completeOwnership(),
2488
+ );
2365
2489
  const waiter: PromptWaiter = {
2366
2490
  invocationKind: skillInvocation ? "skill" : "prompt",
2367
2491
  clientRef,
@@ -2382,31 +2506,53 @@ export class AcpAgent implements Agent {
2382
2506
  activity: new PromptActivity(),
2383
2507
  observedTurnActivity: false,
2384
2508
  observedToolExecution: false,
2509
+ settlement,
2385
2510
  resolve,
2386
2511
  reject,
2387
2512
  };
2388
- record.activePrompt = waiter;
2389
- // The watchdog may reject this waiter while the SDK acknowledgement request is still
2390
- // pending. Retain the original promise for the caller, but mark that delayed rejection
2391
- // as observed until prompt() can resume and await it.
2392
- void response.catch(() => undefined);
2393
- // Silence has to be bounded from the moment the prompt owns the session: a host that
2394
- // dies before it ever answers is exactly the failure that leaves the client running.
2395
- // Provider preflight is part of that window — it awaits a lease the host may never win
2396
- // — so the bound is armed before it, not after (issue #5658). The arm below re-arms
2397
- // this same waiter once preflight clears, and #armPromptWatchdog cancels the previous
2398
- // timer first, so only one timer is ever live.
2399
- this.#armPromptWatchdog(params.sessionId, record, waiter);
2400
- // The watchdog settles `response`, not this await, so a preflight that never returns
2401
- // would pin session/prompt open with the caller's promise already rejected. Racing the
2402
- // settlement lets the re-checks below hand that rejection back to the caller.
2403
- const settlement = response.then(
2404
- () => undefined,
2405
- () => undefined,
2406
- );
2407
2513
  try {
2408
- await Promise.race([record.adapter.ensureProviders(), settlement]);
2409
- } catch (error) {
2514
+ record.activePrompt = waiter;
2515
+ if (record.pendingPromptAdmission === admissionReservation) record.pendingPromptAdmission = undefined;
2516
+ // The watchdog may reject this waiter while the SDK acknowledgement request is still
2517
+ // pending. Retain the original promise for the caller, but mark that delayed rejection
2518
+ // as observed until prompt() can resume and await it.
2519
+ void response.catch(() => undefined);
2520
+ // Silence has to be bounded from the moment the prompt owns the session: a host that
2521
+ // dies before it ever answers is exactly the failure that leaves the client running.
2522
+ // Provider preflight is part of that window — it awaits a lease the host may never win
2523
+ // — so the bound is armed before it, not after (issue #5658). The arm below re-arms
2524
+ // this same waiter once preflight clears, and #armPromptWatchdog cancels the previous
2525
+ // timer first, so only one timer is ever live.
2526
+ this.#armPromptWatchdog(params.sessionId, record, waiter);
2527
+ // The watchdog settles `response`, not this await, so a preflight that never returns
2528
+ // would pin session/prompt open with the caller's promise already rejected. Racing the
2529
+ // settlement lets the re-checks below hand that rejection back to the caller.
2530
+ try {
2531
+ await Promise.race([record.adapter.ensureProviders(), settlement]);
2532
+ } catch (error) {
2533
+ waiter.awaitingProviderPreflight = false;
2534
+ if (waiter.settled || record.activePrompt !== waiter) {
2535
+ this.#releaseRetiredPromptAcknowledgement(params.sessionId, waiter);
2536
+ return await response;
2537
+ }
2538
+ if (record.cancelRequested && (waiter.cancelAcknowledged || waiter.cancelBeforeAdmission)) {
2539
+ this.#releaseRetiredPromptAcknowledgement(params.sessionId, waiter);
2540
+ await this.#settleCancelledPrompt(params.sessionId, record, waiter);
2541
+ return await response;
2542
+ }
2543
+ if (record.activePrompt === waiter) {
2544
+ record.activePrompt = undefined;
2545
+ record.busy = record.backgroundBusy;
2546
+ clearPromptWatchdog(waiter);
2547
+ // #5526's guarded release replaces the unguarded delete: it only clears
2548
+ // the entry when it still belongs to THIS waiter, so a successor's
2549
+ // acknowledgement is never dropped. Strictly narrower than the delete
2550
+ // it supersedes; dev's watchdog clear above is unaffected.
2551
+ this.#releaseRetiredPromptAcknowledgement(params.sessionId, waiter);
2552
+ void this.#publishPromptPhaseIdle(params.sessionId, record.adapter);
2553
+ }
2554
+ throw error;
2555
+ }
2410
2556
  waiter.awaitingProviderPreflight = false;
2411
2557
  if (waiter.settled || record.activePrompt !== waiter) {
2412
2558
  this.#releaseRetiredPromptAcknowledgement(params.sessionId, waiter);
@@ -2417,354 +2563,370 @@ export class AcpAgent implements Agent {
2417
2563
  await this.#settleCancelledPrompt(params.sessionId, record, waiter);
2418
2564
  return await response;
2419
2565
  }
2420
- if (record.activePrompt === waiter) {
2421
- record.activePrompt = undefined;
2422
- record.busy = record.backgroundBusy;
2423
- clearPromptWatchdog(waiter);
2424
- // #5526's guarded release replaces the unguarded delete: it only clears
2425
- // the entry when it still belongs to THIS waiter, so a successor's
2426
- // acknowledgement is never dropped. Strictly narrower than the delete
2427
- // it supersedes; dev's watchdog clear above is unaffected.
2428
- this.#releaseRetiredPromptAcknowledgement(params.sessionId, waiter);
2429
- void this.#publishPromptPhaseIdle(params.sessionId, record.adapter);
2430
- }
2431
- throw error;
2432
- }
2433
- waiter.awaitingProviderPreflight = false;
2434
- if (waiter.settled || record.activePrompt !== waiter) {
2435
- this.#releaseRetiredPromptAcknowledgement(params.sessionId, waiter);
2436
- return await response;
2437
- }
2438
- if (record.cancelRequested && (waiter.cancelAcknowledged || waiter.cancelBeforeAdmission)) {
2439
- this.#releaseRetiredPromptAcknowledgement(params.sessionId, waiter);
2440
- await this.#settleCancelledPrompt(params.sessionId, record, waiter);
2441
- return await response;
2442
- }
2443
2566
 
2444
- // Re-arm now that preflight is behind us: the bound armed before it already covered
2445
- // this prompt's ownership window, and this call replaces that timer rather than
2446
- // racing it, so the dispatched turn gets the full inactivity bound of its own.
2447
- this.#armPromptWatchdog(params.sessionId, record, waiter);
2448
- // Echo the user's own message back as `user_message_chunk`. Clients render their
2449
- // transcript from session/update, so without this a prompt's text and any attached
2450
- // image never appear in the client UI — only the agent's reply does. Replay
2451
- // (session/load) already emits these; a live turn must too, and the image blocks
2452
- // must be published verbatim so attachments are visible, not just fed to the model.
2453
- // A first-turn readiness retry (issue #5574) skips the echo: the message was already
2454
- // published on the first attempt and re-echoing would duplicate it in the transcript.
2455
- if (echoUserMessage)
2456
- for (const block of params.prompt) {
2457
- if (block.type !== "text" && block.type !== "image") continue;
2458
- if (block.type === "text" && block.text.length === 0) continue;
2459
- await this.#publishSessionUpdate(
2460
- params.sessionId,
2461
- {
2462
- sessionId: params.sessionId,
2463
- update: { sessionUpdate: "user_message_chunk", content: block },
2464
- },
2465
- record.adapter,
2466
- );
2467
- }
2468
- if (waiter.settled || record.activePrompt !== waiter) {
2469
- return await response;
2470
- }
2471
- if (record.cancelRequested && (waiter.cancelAcknowledged || waiter.cancelBeforeAdmission)) {
2472
- await this.#settleCancelledPrompt(params.sessionId, record, waiter);
2473
- return await response;
2474
- }
2475
- waiter.dispatched = true;
2476
- // The turn is now dispatched to the host: this prompt owned the session's first turn,
2477
- // so it — not a preflight rejection that threw before this point — is what settles
2478
- // `firstPromptDone` in `prompt()` (review P2).
2479
- if (retryReservation) retryReservation.admitted = true;
2480
-
2481
- waiter.acknowledgementPending = true;
2482
- const promptAdapter = record.adapter;
2483
- const acknowledgementTask = (async (): Promise<PromptResponse> => {
2484
- if (waiter.settled || record.activePrompt !== waiter) return await response;
2485
- const acknowledgement = skillInvocation
2486
- ? await promptAdapter.control("skill.invoke", { ...skillInvocation, clientRef })
2487
- : await promptAdapter.prompt({
2488
- text: payload.text,
2489
- clientRef,
2490
- ...(payload.images.length ? { images: payload.images } : {}),
2491
- });
2492
- // A recovered waiter already owns its exact identity; a late ack cannot rebind it.
2493
- if (waiter.settled && waiter.acknowledged) return await response;
2494
-
2495
- const acknowledgementCorrelation = promptAcknowledgement(acknowledgement);
2496
- if (!acknowledgementCorrelation)
2497
- throw new AcpSdkAdapterError(
2498
- "invalid_prompt_acknowledgement",
2499
- "SDK prompt acknowledgement must accept the prompt and include commandId and turnId.",
2500
- );
2501
- if (
2502
- this.#sessions.get(params.sessionId) !== record ||
2503
- record.adapter !== promptAdapter ||
2504
- record.activePrompt !== waiter
2505
- ) {
2506
- const current = this.#sessions.get(params.sessionId);
2507
- const provisionalOwner = current?.uncertainAbortOwner;
2508
- if (
2509
- provisionalOwner?.kind === waiter.invocationKind &&
2510
- provisionalOwner.clientRef === waiter.clientRef &&
2511
- provisionalOwner.correlation === undefined &&
2512
- this.#hasRetiredPromptCorrelation(params.sessionId, record, acknowledgementCorrelation)
2513
- ) {
2514
- // A stale ACK cannot bind a provisional abort owner. Keep the fence
2515
- // until this session is retired or new exact evidence is available.
2516
- waiter.deferredFrames.length = 0;
2517
- return await response;
2518
- }
2519
- this.#bindUncertainAbortOwner(params.sessionId, waiter, acknowledgementCorrelation);
2520
- this.#rememberSettledPromptCorrelation(params.sessionId, record, acknowledgementCorrelation);
2521
- const retainedOwner = current?.uncertainAbortOwner;
2522
- if (
2523
- current &&
2524
- retainedOwner?.kind === waiter.invocationKind &&
2525
- retainedOwner.clientRef === waiter.clientRef &&
2526
- retainedOwner.correlation &&
2527
- correlationsExactlyMatch(retainedOwner.correlation, acknowledgementCorrelation)
2528
- ) {
2529
- await current.frameTail;
2530
- const deferredFrames = waiter.deferredFrames.splice(0);
2531
- let reconciledDeferredTerminal = false;
2532
- for (const deferred of deferredFrames) {
2533
- const deferredEvent = receivedSdkEvent(deferred.frame)?.event;
2534
- if (!deferredEvent) continue;
2535
- const deferredOutcome = terminalOutcome(deferredEvent);
2536
- const validDeferredTerminal =
2537
- (deferredEvent.type === "agent_end" && deferredOutcome !== undefined) ||
2538
- (deferredEvent.type === "agent_failed" && deferredOutcome?.kind === "failed");
2539
- const deferredCorrelation = sdkFrameCorrelation(deferred.frame, deferredEvent);
2540
- if (
2541
- !validDeferredTerminal ||
2542
- !sdkFrameBelongsToSession(deferred.frame, deferredEvent, params.sessionId) ||
2543
- !deferredCorrelation ||
2544
- !hasCompleteCorrelation(deferredCorrelation) ||
2545
- !correlationsExactlyMatch(deferredCorrelation, acknowledgementCorrelation)
2546
- )
2547
- continue;
2548
- waiter.terminalReserved = true;
2549
- await this.#handleSdkFrame(
2550
- params.sessionId,
2551
- current.adapter,
2552
- deferred.frame,
2553
- current.publicationGeneration,
2554
- );
2555
- reconciledDeferredTerminal = true;
2556
- break;
2557
- }
2558
- if (!reconciledDeferredTerminal)
2559
- void this.#recoverUncertainAbortAfterLateAcknowledgement(
2560
- params.sessionId,
2561
- current.adapter,
2562
- retainedOwner,
2563
- );
2564
- } else {
2565
- waiter.deferredFrames.length = 0;
2566
- }
2567
- return await response;
2568
- }
2569
- if (this.#hasRetiredPromptCorrelation(params.sessionId, record, acknowledgementCorrelation)) {
2570
- try {
2571
- const abortAcknowledgement = await record.adapter.cancel("turn");
2572
- if (!isAbortAcknowledged(abortAcknowledgement, "turn"))
2573
- throw new Error("SDK did not confirm retirement of the reused prompt correlation.");
2574
- } catch (error) {
2575
- await this.#failSession(
2567
+ // Re-arm now that preflight is behind us: the bound armed before it already covered
2568
+ // this prompt's ownership window, and this call replaces that timer rather than
2569
+ // racing it, so the dispatched turn gets the full inactivity bound of its own.
2570
+ this.#armPromptWatchdog(params.sessionId, record, waiter);
2571
+ // Echo the user's own message back as `user_message_chunk`. Clients render their
2572
+ // transcript from session/update, so without this a prompt's text and any attached
2573
+ // image never appear in the client UI — only the agent's reply does. Replay
2574
+ // (session/load) already emits these; a live turn must too, and the image blocks
2575
+ // must be published verbatim so attachments are visible, not just fed to the model.
2576
+ // A first-turn readiness retry (issue #5574) skips the echo: the message was already
2577
+ // published on the first attempt and re-echoing would duplicate it in the transcript.
2578
+ if (echoUserMessage)
2579
+ for (const block of params.prompt) {
2580
+ if (block.type !== "text" && block.type !== "image") continue;
2581
+ if (block.type === "text" && block.text.length === 0) continue;
2582
+ await this.#publishSessionUpdate(
2576
2583
  params.sessionId,
2584
+ {
2585
+ sessionId: params.sessionId,
2586
+ update: { sessionUpdate: "user_message_chunk", content: block },
2587
+ },
2577
2588
  record.adapter,
2578
- new AcpSdkAdapterError(
2579
- "connection_closed",
2580
- `ACP could not retire a prompt with a reused correlation: ${error instanceof Error ? error.message : String(error)}`,
2581
- ),
2582
2589
  );
2583
- throw error;
2584
2590
  }
2585
- throw new AcpSdkAdapterError(
2586
- "invalid_prompt_acknowledgement",
2587
- "SDK prompt acknowledgement reused a settled commandId/turnId pair.",
2588
- );
2589
- }
2590
- // Retain the acknowledgement ingress boundary with its complete correlation.
2591
- waiter.boundary = record.inboundSequence;
2592
- waiter.correlation = acknowledgementCorrelation;
2593
- waiter.acknowledged = true;
2594
- this.#bindUncertainAbortOwner(params.sessionId, waiter, acknowledgementCorrelation);
2595
- if (promptWaiterRetired(record, waiter)) {
2596
- this.#rememberSettledPromptCorrelation(params.sessionId, record, acknowledgementCorrelation);
2591
+ if (waiter.settled || record.activePrompt !== waiter) {
2597
2592
  return await response;
2598
2593
  }
2599
- if (waiter.cancelBeforeAdmission && record.cancelRequested) {
2600
- const scope = waiter.cancelBeforeAdmissionScope ?? "turn";
2601
- // Keep the intent until an acknowledged abort or the prompt terminal settles it.
2602
- void this.cancel({
2603
- sessionId: params.sessionId,
2604
- _meta: { gjc: { abortScope: scope } },
2605
- } as CancelNotification).catch(error =>
2606
- logger.warn("ACP could not retry a cancel after prompt admission", {
2607
- sessionId: params.sessionId,
2608
- error: error instanceof Error ? error.message : String(error),
2609
- }),
2610
- );
2594
+ if (record.cancelRequested && (waiter.cancelAcknowledged || waiter.cancelBeforeAdmission)) {
2595
+ await this.#settleCancelledPrompt(params.sessionId, record, waiter);
2596
+ return await response;
2611
2597
  }
2612
- // Frames held while ownership was unknown belong to this prompt only when the
2613
- // acknowledgement proves their complete correlation matches exactly.
2614
- const deferredActivityFrames = waiter.deferredActivityFrames.splice(0);
2615
- let observedDeferredActivity = false;
2616
- for (const deferredFrame of deferredActivityFrames) {
2617
- const deferredEvent = receivedSdkEvent(deferredFrame)?.event;
2618
- const matchesWaiter = correlationsExactlyMatch(
2619
- waiter.correlation,
2620
- watchdogCorrelationFrom(deferredFrame, deferredEvent),
2621
- );
2622
- if (deferredEvent?.type === "agent_start" && !matchesWaiter) {
2623
- const deferredCorrelation = sdkFrameCorrelation(deferredFrame, deferredEvent);
2624
- const tombstoned =
2625
- deferredCorrelation &&
2626
- record.settledPromptCorrelations.some(settled =>
2627
- correlationsExactlyMatch(settled, deferredCorrelation),
2628
- );
2629
- if (!tombstoned) {
2630
- record.backgroundBusy = true;
2631
- if (deferredCorrelation && hasCompleteCorrelation(deferredCorrelation)) {
2598
+ waiter.dispatched = true;
2599
+ record.sdkIdle = false;
2600
+ // The turn is now dispatched to the host: this prompt owned the session's first turn,
2601
+ // so it — not a preflight rejection that threw before this point — is what settles
2602
+ // `firstPromptDone` in `prompt()` (review P2).
2603
+ if (retryReservation) retryReservation.admitted = true;
2604
+
2605
+ waiter.acknowledgementPending = true;
2606
+ const promptAdapter = record.adapter;
2607
+ const acknowledgementTask = (async (): Promise<PromptResponse> => {
2608
+ if (waiter.settled || record.activePrompt !== waiter) return await response;
2609
+ const submit = async (): Promise<unknown> =>
2610
+ skillInvocation
2611
+ ? await promptAdapter.control("skill.invoke", { ...skillInvocation, clientRef })
2612
+ : await promptAdapter.prompt({
2613
+ text: payload.text,
2614
+ clientRef,
2615
+ ...(payload.images.length ? { images: payload.images } : {}),
2616
+ });
2617
+ let acknowledgement: unknown;
2618
+ try {
2619
+ acknowledgement = await submit();
2620
+ } catch (error) {
2621
+ if (!(error instanceof SdkClientError || error instanceof AcpSdkAdapterError) || error.code !== "busy")
2622
+ throw error;
2623
+ if (
2624
+ record.activePrompt !== waiter ||
2625
+ waiter.settled ||
2626
+ record.cancelRequested ||
2627
+ waiter.cancelAcknowledged ||
2628
+ waiter.cancelBeforeAdmission
2629
+ )
2630
+ throw error;
2631
+ const ready = await this.#waitForSessionIdle(record);
2632
+ if (!ready) throw error;
2633
+ if (
2634
+ record.activePrompt !== waiter ||
2635
+ waiter.settled ||
2636
+ record.cancelRequested ||
2637
+ waiter.cancelAcknowledged ||
2638
+ waiter.cancelBeforeAdmission
2639
+ )
2640
+ throw error;
2641
+ acknowledgement = await submit();
2642
+ }
2643
+ // A recovered waiter already owns its exact identity; a late ack cannot rebind it.
2644
+ if (waiter.settled && waiter.acknowledged) return await response;
2645
+
2646
+ const acknowledgementCorrelation = promptAcknowledgement(acknowledgement);
2647
+ if (!acknowledgementCorrelation)
2648
+ throw new AcpSdkAdapterError(
2649
+ "invalid_prompt_acknowledgement",
2650
+ "SDK prompt acknowledgement must accept the prompt and include commandId and turnId.",
2651
+ );
2652
+ if (
2653
+ this.#sessions.get(params.sessionId) !== record ||
2654
+ record.adapter !== promptAdapter ||
2655
+ record.activePrompt !== waiter
2656
+ ) {
2657
+ const current = this.#sessions.get(params.sessionId);
2658
+ const provisionalOwner = current?.uncertainAbortOwner;
2659
+ if (
2660
+ provisionalOwner?.kind === waiter.invocationKind &&
2661
+ provisionalOwner.clientRef === waiter.clientRef &&
2662
+ provisionalOwner.correlation === undefined &&
2663
+ this.#hasRetiredPromptCorrelation(params.sessionId, record, acknowledgementCorrelation)
2664
+ ) {
2665
+ // A stale ACK cannot bind a provisional abort owner. Keep the fence
2666
+ // until this session is retired or new exact evidence is available.
2667
+ waiter.deferredFrames.length = 0;
2668
+ return await response;
2669
+ }
2670
+ this.#bindUncertainAbortOwner(params.sessionId, waiter, acknowledgementCorrelation);
2671
+ this.#rememberSettledPromptCorrelation(params.sessionId, record, acknowledgementCorrelation);
2672
+ const retainedOwner = current?.uncertainAbortOwner;
2673
+ if (
2674
+ current &&
2675
+ retainedOwner?.kind === waiter.invocationKind &&
2676
+ retainedOwner.clientRef === waiter.clientRef &&
2677
+ retainedOwner.correlation &&
2678
+ correlationsExactlyMatch(retainedOwner.correlation, acknowledgementCorrelation)
2679
+ ) {
2680
+ await current.frameTail;
2681
+ const deferredFrames = waiter.deferredFrames.splice(0);
2682
+ let reconciledDeferredTerminal = false;
2683
+ for (const deferred of deferredFrames) {
2684
+ const deferredEvent = receivedSdkEvent(deferred.frame)?.event;
2685
+ if (!deferredEvent) continue;
2686
+ const deferredOutcome = terminalOutcome(deferredEvent);
2687
+ const validDeferredTerminal =
2688
+ (deferredEvent.type === "agent_end" && deferredOutcome !== undefined) ||
2689
+ (deferredEvent.type === "agent_failed" && deferredOutcome?.kind === "failed");
2690
+ const deferredCorrelation = sdkFrameCorrelation(deferred.frame, deferredEvent);
2632
2691
  if (
2633
- !record.backgroundCorrelations.some(owner =>
2634
- correlationsExactlyMatch(owner, deferredCorrelation),
2635
- )
2692
+ !validDeferredTerminal ||
2693
+ !sdkFrameBelongsToSession(deferred.frame, deferredEvent, params.sessionId) ||
2694
+ !deferredCorrelation ||
2695
+ !hasCompleteCorrelation(deferredCorrelation) ||
2696
+ !correlationsExactlyMatch(deferredCorrelation, acknowledgementCorrelation)
2636
2697
  )
2637
- record.backgroundCorrelations.push(deferredCorrelation);
2638
- } else record.backgroundAnonymousCount++;
2698
+ continue;
2699
+ waiter.terminalReserved = true;
2700
+ await this.#handleSdkFrame(
2701
+ params.sessionId,
2702
+ current.adapter,
2703
+ deferred.frame,
2704
+ current.publicationGeneration,
2705
+ );
2706
+ reconciledDeferredTerminal = true;
2707
+ break;
2708
+ }
2709
+ if (!reconciledDeferredTerminal)
2710
+ void this.#recoverUncertainAbortAfterLateAcknowledgement(
2711
+ params.sessionId,
2712
+ current.adapter,
2713
+ retainedOwner,
2714
+ );
2715
+ } else {
2716
+ waiter.deferredFrames.length = 0;
2639
2717
  }
2718
+ return await response;
2640
2719
  }
2641
- if (matchesWaiter) {
2642
- this.#observePromptActivity(waiter, deferredFrame);
2643
- if (waiter.observedTurnActivity) record.promptObservedActivity = true;
2644
- if (waiter.observedToolExecution) record.promptObservedToolExecution = true;
2645
- observedDeferredActivity = true;
2646
- }
2647
- }
2648
- if (observedDeferredActivity) this.#armPromptWatchdog(params.sessionId, record, waiter);
2649
- const deferred = waiter.deferredFrames.splice(0);
2650
- for (const { frame: deferredFrame, publicationGeneration: deferredGeneration } of deferred) {
2651
- const deferredEvent = receivedSdkEvent(deferredFrame)?.event;
2652
- if (!deferredEvent) continue;
2653
- const deferredCorrelation = sdkFrameCorrelation(deferredFrame, deferredEvent) ?? {};
2654
- const deferredOutcome =
2655
- deferredEvent.type === "agent_end" || deferredEvent.type === "agent_failed"
2656
- ? terminalOutcome(deferredEvent)
2657
- : undefined;
2658
- const deferredIsTerminal =
2659
- (deferredEvent.type === "agent_end" && deferredOutcome !== undefined) ||
2660
- (deferredEvent.type === "agent_failed" && deferredOutcome?.kind === "failed");
2661
- if (!hasCompleteCorrelation(deferredCorrelation)) {
2662
- if (deferredIsTerminal)
2663
- logDroppedPromptTerminal(
2720
+ if (this.#hasRetiredPromptCorrelation(params.sessionId, record, acknowledgementCorrelation)) {
2721
+ try {
2722
+ const abortAcknowledgement = await record.adapter.cancel("turn");
2723
+ if (!isAbortAcknowledged(abortAcknowledgement, "turn"))
2724
+ throw new Error("SDK did not confirm retirement of the reused prompt correlation.");
2725
+ } catch (error) {
2726
+ await this.#failSession(
2664
2727
  params.sessionId,
2665
- deferredEvent,
2666
- "incomplete_correlation",
2667
- deferredCorrelation,
2668
- waiter.correlation,
2728
+ record.adapter,
2729
+ new AcpSdkAdapterError(
2730
+ "connection_closed",
2731
+ `ACP could not retire a prompt with a reused correlation: ${error instanceof Error ? error.message : String(error)}`,
2732
+ ),
2669
2733
  );
2670
- continue;
2734
+ throw error;
2735
+ }
2736
+ throw new AcpSdkAdapterError(
2737
+ "invalid_prompt_acknowledgement",
2738
+ "SDK prompt acknowledgement reused a settled commandId/turnId pair.",
2739
+ );
2671
2740
  }
2672
- const matchesPrompt = correlationsExactlyMatch(waiter.correlation, deferredCorrelation);
2673
- if (
2674
- !matchesPrompt &&
2675
- deferredIsTerminal &&
2676
- record.backgroundCorrelations.some(owner => correlationsExactlyMatch(owner, deferredCorrelation))
2677
- ) {
2678
- record.backgroundCorrelations = record.backgroundCorrelations.filter(
2679
- owner => !correlationsExactlyMatch(owner, deferredCorrelation),
2741
+ // Retain the acknowledgement ingress boundary with its complete correlation.
2742
+ waiter.boundary = record.inboundSequence;
2743
+ waiter.correlation = acknowledgementCorrelation;
2744
+ waiter.acknowledged = true;
2745
+ this.#bindUncertainAbortOwner(params.sessionId, waiter, acknowledgementCorrelation);
2746
+ if (promptWaiterRetired(record, waiter)) {
2747
+ this.#rememberSettledPromptCorrelation(params.sessionId, record, acknowledgementCorrelation);
2748
+ return await response;
2749
+ }
2750
+ if (waiter.cancelBeforeAdmission && record.cancelRequested) {
2751
+ const scope = waiter.cancelBeforeAdmissionScope ?? "turn";
2752
+ // Keep the intent until an acknowledged abort or the prompt terminal settles it.
2753
+ void this.cancel({
2754
+ sessionId: params.sessionId,
2755
+ _meta: { gjc: { abortScope: scope } },
2756
+ } as CancelNotification).catch(error =>
2757
+ logger.warn("ACP could not retry a cancel after prompt admission", {
2758
+ sessionId: params.sessionId,
2759
+ error: error instanceof Error ? error.message : String(error),
2760
+ }),
2680
2761
  );
2681
- record.backgroundBusy = record.backgroundAnonymousCount > 0 || record.backgroundCorrelations.length > 0;
2682
- record.busy = true;
2683
- continue;
2684
2762
  }
2685
- if (
2686
- !matchesPrompt &&
2687
- record.settledPromptCorrelations.some(settled => correlationsExactlyMatch(settled, deferredCorrelation))
2688
- )
2689
- continue;
2690
- if (!matchesPrompt) {
2691
- if (deferredIsTerminal)
2692
- logDroppedPromptTerminal(
2693
- params.sessionId,
2694
- deferredEvent,
2695
- "correlation_mismatch",
2696
- deferredCorrelation,
2697
- waiter.correlation,
2698
- );
2699
- continue;
2763
+ // Frames held while ownership was unknown belong to this prompt only when the
2764
+ // acknowledgement proves their complete correlation matches exactly.
2765
+ const deferredActivityFrames = waiter.deferredActivityFrames.splice(0);
2766
+ let observedDeferredActivity = false;
2767
+ for (const deferredFrame of deferredActivityFrames) {
2768
+ const deferredEvent = receivedSdkEvent(deferredFrame)?.event;
2769
+ const matchesWaiter = correlationsExactlyMatch(
2770
+ waiter.correlation,
2771
+ watchdogCorrelationFrom(deferredFrame, deferredEvent),
2772
+ );
2773
+ if (deferredEvent?.type === "agent_start" && !matchesWaiter) {
2774
+ const deferredCorrelation = sdkFrameCorrelation(deferredFrame, deferredEvent);
2775
+ const tombstoned =
2776
+ deferredCorrelation &&
2777
+ record.settledPromptCorrelations.some(settled =>
2778
+ correlationsExactlyMatch(settled, deferredCorrelation),
2779
+ );
2780
+ if (!tombstoned) {
2781
+ record.backgroundBusy = true;
2782
+ if (deferredCorrelation && hasCompleteCorrelation(deferredCorrelation)) {
2783
+ if (
2784
+ !record.backgroundCorrelations.some(owner =>
2785
+ correlationsExactlyMatch(owner, deferredCorrelation),
2786
+ )
2787
+ )
2788
+ record.backgroundCorrelations.push(deferredCorrelation);
2789
+ } else record.backgroundAnonymousCount++;
2790
+ }
2791
+ }
2792
+ if (matchesWaiter) {
2793
+ this.#observePromptActivity(waiter, deferredFrame);
2794
+ if (waiter.observedTurnActivity) record.promptObservedActivity = true;
2795
+ if (waiter.observedToolExecution) record.promptObservedToolExecution = true;
2796
+ observedDeferredActivity = true;
2797
+ }
2700
2798
  }
2701
- if (deferredIsTerminal) {
2702
- waiter.terminalReserved = true;
2703
- clearPromptWatchdog(waiter);
2799
+ if (observedDeferredActivity) this.#armPromptWatchdog(params.sessionId, record, waiter);
2800
+ const deferred = waiter.deferredFrames.splice(0);
2801
+ for (const { frame: deferredFrame, publicationGeneration: deferredGeneration } of deferred) {
2802
+ const deferredEvent = receivedSdkEvent(deferredFrame)?.event;
2803
+ if (!deferredEvent) continue;
2804
+ const deferredCorrelation = sdkFrameCorrelation(deferredFrame, deferredEvent) ?? {};
2805
+ const deferredOutcome =
2806
+ deferredEvent.type === "agent_end" || deferredEvent.type === "agent_failed"
2807
+ ? terminalOutcome(deferredEvent)
2808
+ : undefined;
2809
+ const deferredIsTerminal =
2810
+ (deferredEvent.type === "agent_end" && deferredOutcome !== undefined) ||
2811
+ (deferredEvent.type === "agent_failed" && deferredOutcome?.kind === "failed");
2812
+ if (!hasCompleteCorrelation(deferredCorrelation)) {
2813
+ if (deferredIsTerminal)
2814
+ logDroppedPromptTerminal(
2815
+ params.sessionId,
2816
+ deferredEvent,
2817
+ "incomplete_correlation",
2818
+ deferredCorrelation,
2819
+ waiter.correlation,
2820
+ );
2821
+ continue;
2822
+ }
2823
+ const matchesPrompt = correlationsExactlyMatch(waiter.correlation, deferredCorrelation);
2824
+ if (
2825
+ !matchesPrompt &&
2826
+ deferredIsTerminal &&
2827
+ record.backgroundCorrelations.some(owner => correlationsExactlyMatch(owner, deferredCorrelation))
2828
+ ) {
2829
+ record.backgroundCorrelations = record.backgroundCorrelations.filter(
2830
+ owner => !correlationsExactlyMatch(owner, deferredCorrelation),
2831
+ );
2832
+ record.backgroundBusy =
2833
+ record.backgroundAnonymousCount > 0 || record.backgroundCorrelations.length > 0;
2834
+ record.busy = true;
2835
+ continue;
2836
+ }
2837
+ if (
2838
+ !matchesPrompt &&
2839
+ record.settledPromptCorrelations.some(settled =>
2840
+ correlationsExactlyMatch(settled, deferredCorrelation),
2841
+ )
2842
+ )
2843
+ continue;
2844
+ if (!matchesPrompt) {
2845
+ if (deferredIsTerminal)
2846
+ logDroppedPromptTerminal(
2847
+ params.sessionId,
2848
+ deferredEvent,
2849
+ "correlation_mismatch",
2850
+ deferredCorrelation,
2851
+ waiter.correlation,
2852
+ );
2853
+ continue;
2854
+ }
2855
+ if (deferredIsTerminal) {
2856
+ waiter.terminalReserved = true;
2857
+ clearPromptWatchdog(waiter);
2858
+ }
2859
+ const task = record.frameTail.then(
2860
+ async () =>
2861
+ await this.#handleSdkFrame(params.sessionId, record.adapter, deferredFrame, deferredGeneration),
2862
+ );
2863
+ record.frameTail = task.catch(async error => {
2864
+ if (deferredGeneration !== record.publicationGeneration) return;
2865
+ if (record.activePrompt?.terminalReserved) return;
2866
+ await this.#failSession(params.sessionId, record.adapter, this.#frameProcessingFailure(error));
2867
+ });
2704
2868
  }
2705
- const task = record.frameTail.then(
2706
- async () =>
2707
- await this.#handleSdkFrame(params.sessionId, record.adapter, deferredFrame, deferredGeneration),
2708
- );
2709
- record.frameTail = task.catch(async error => {
2710
- if (deferredGeneration !== record.publicationGeneration) return;
2711
- if (record.activePrompt?.terminalReserved) return;
2712
- await this.#failSession(params.sessionId, record.adapter, this.#frameProcessingFailure(error));
2869
+ this.#settlePrompt(params.sessionId, record, waiter);
2870
+ return await response;
2871
+ })()
2872
+ .catch(async error => {
2873
+ if (
2874
+ !(error instanceof SdkClientError || error instanceof AcpSdkAdapterError) ||
2875
+ error.code !== "uncertain_after_send"
2876
+ )
2877
+ throw error;
2878
+ if (record.adapter === promptAdapter)
2879
+ this.#startUncertainPromptRecovery(params.sessionId, record, waiter);
2880
+ return await response;
2881
+ })
2882
+ .finally(() => {
2883
+ waiter.acknowledgementPending = false;
2884
+ this.#releaseRetiredPromptAcknowledgement(params.sessionId, waiter);
2713
2885
  });
2714
- }
2715
- this.#settlePrompt(params.sessionId, record, waiter);
2716
- return await response;
2717
- })()
2718
- .catch(async error => {
2886
+ // Settlement can win before the SDK answers `turn.prompt`; acknowledgement processing
2887
+ // must remain alive to tombstone its eventual correlation without holding the ACP caller.
2888
+ void acknowledgementTask.catch(() => undefined);
2889
+ try {
2890
+ return await Promise.race([response, acknowledgementTask]);
2891
+ } catch (error) {
2892
+ if (waiter.cancelAttempt && (await waiter.cancelAttempt) && waiter.settled) return await response;
2893
+ waiter.deferredFrames.length = 0;
2894
+ waiter.deferredActivityFrames.length = 0;
2895
+ clearPromptWatchdog(waiter);
2896
+ waiter.terminal = undefined;
2897
+ waiter.settled = true;
2898
+ if (record.activePrompt === waiter) {
2899
+ record.activePrompt = undefined;
2900
+ record.busy = record.backgroundBusy;
2901
+ void this.#publishPromptPhaseIdle(params.sessionId, record.adapter);
2902
+ }
2903
+ // Keep a late terminal for this (cancelled) turn closed, exactly like the
2904
+ // other settlement paths, so an aborted run's trailing frame can never
2905
+ // publish over a later prompt that reuses the identity.
2906
+ this.#rememberSettledPromptCorrelation(params.sessionId, record, waiter.correlation);
2907
+ // A prompt cancelled before the SDK acknowledged it still ends this turn by
2908
+ // client request, and ACP is explicit: "Agents MUST catch these errors and
2909
+ // return the semantically meaningful `cancelled` stop reason, so that Clients
2910
+ // can reliably confirm the cancellation." Surfacing the transport's `busy`
2911
+ // rejection instead would show the user a spurious error for their own cancel.
2719
2912
  if (
2720
- !(error instanceof SdkClientError || error instanceof AcpSdkAdapterError) ||
2721
- error.code !== "uncertain_after_send"
2722
- )
2723
- throw error;
2724
- if (record.adapter === promptAdapter) this.#startUncertainPromptRecovery(params.sessionId, record, waiter);
2725
- return await response;
2726
- })
2727
- .finally(() => {
2728
- waiter.acknowledgementPending = false;
2729
- this.#releaseRetiredPromptAcknowledgement(params.sessionId, waiter);
2730
- });
2731
- // Settlement can win before the SDK answers `turn.prompt`; acknowledgement processing
2732
- // must remain alive to tombstone its eventual correlation without holding the ACP caller.
2733
- void acknowledgementTask.catch(() => undefined);
2734
- try {
2735
- return await Promise.race([response, acknowledgementTask]);
2736
- } catch (error) {
2737
- if (waiter.cancelAttempt && (await waiter.cancelAttempt) && waiter.settled) return await response;
2738
- waiter.deferredFrames.length = 0;
2739
- waiter.deferredActivityFrames.length = 0;
2740
- clearPromptWatchdog(waiter);
2741
- waiter.terminal = undefined;
2742
- waiter.settled = true;
2743
- if (record.activePrompt === waiter) {
2744
- record.activePrompt = undefined;
2745
- record.busy = record.backgroundBusy;
2746
- void this.#publishPromptPhaseIdle(params.sessionId, record.adapter);
2747
- }
2748
- // Keep a late terminal for this (cancelled) turn closed, exactly like the
2749
- // other settlement paths, so an aborted run's trailing frame can never
2750
- // publish over a later prompt that reuses the identity.
2751
- this.#rememberSettledPromptCorrelation(params.sessionId, record, waiter.correlation);
2752
- // A prompt cancelled before the SDK acknowledged it still ends this turn by
2753
- // client request, and ACP is explicit: "Agents MUST catch these errors and
2754
- // return the semantically meaningful `cancelled` stop reason, so that Clients
2755
- // can reliably confirm the cancellation." Surfacing the transport's `busy`
2756
- // rejection instead would show the user a spurious error for their own cancel.
2757
- if (
2758
- record.cancelRequested &&
2759
- (waiter.cancelAcknowledged || (waiter.cancelBeforeAdmission && !waiter.acknowledged && !waiter.dispatched))
2760
- ) {
2761
- record.cancelRequested = false;
2762
- // The client's turn is settled by the return; the advisory idle publication
2763
- // must not gate it and must still be attempted so the running phase is
2764
- // released (gjcRunning:false) instead of spinning behind a settled cancel.
2765
- return { stopReason: "cancelled" };
2913
+ record.cancelRequested &&
2914
+ (waiter.cancelAcknowledged ||
2915
+ (waiter.cancelBeforeAdmission && !waiter.acknowledged && !waiter.dispatched))
2916
+ ) {
2917
+ record.cancelRequested = false;
2918
+ waiter.resolve({ stopReason: "cancelled" });
2919
+ // The client's turn is settled by the return; the advisory idle publication
2920
+ // must not gate it and must still be attempted so the running phase is
2921
+ // released (gjcRunning:false) instead of spinning behind a settled cancel.
2922
+ return { stopReason: "cancelled" };
2923
+ }
2924
+ throw error;
2766
2925
  }
2767
- throw error;
2926
+ } finally {
2927
+ // Exception exits can release ownership without settling the public response.
2928
+ // Wake successor admission without replacing the predecessor's original result.
2929
+ completeOwnership();
2768
2930
  }
2769
2931
  }
2770
2932
 
@@ -2774,6 +2936,7 @@ export class AcpAgent implements Agent {
2774
2936
  // Record the client's intent before awaiting the SDK so a prompt that rejects
2775
2937
  // mid-cancel (e.g. preflight `busy`) can still settle as `cancelled`.
2776
2938
  record.cancelRequested = true;
2939
+ this.#settlePendingPromptAdmission(record, { kind: "cancelled" });
2777
2940
  // C04 terminal abort: an external client cancel stops the current turn
2778
2941
  // (`scope:"turn"`, the default, matching the SDK `turn.abort` default and
2779
2942
  // other ACP clients' cancel behavior). A client that also wants exact owned
@@ -2944,6 +3107,16 @@ export class AcpAgent implements Agent {
2944
3107
  }
2945
3108
  }
2946
3109
 
3110
+ #settlePendingPromptAdmission(record: SessionRecord, settlement: PromptAdmissionSettlement): void {
3111
+ const reservation = record.pendingPromptAdmission;
3112
+ if (!reservation) return;
3113
+ reservation.settlement ??= settlement;
3114
+ if (settlement.kind === "cancelled") reservation.cancelled = true;
3115
+ reservation.wake();
3116
+ for (const resolve of record.idleWaiters) resolve();
3117
+ record.idleWaiters.clear();
3118
+ }
3119
+
2947
3120
  /**
2948
3121
  * `aborted: true` means the run is gone, so the pending prompt is already over even
2949
3122
  * if no normalized terminal follows. Without this the waiter stays pending forever:
@@ -3249,6 +3422,7 @@ export class AcpAgent implements Agent {
3249
3422
  this.#pendingRouterFrames.set(id, bufferedFrames);
3250
3423
  try {
3251
3424
  await this.#ensureRouterReady();
3425
+ await this.#router.reconcile({ waitForReplay: false });
3252
3426
  if (lifecycleResult) await this.#router.adoptLifecycleResult(lifecycleResult, { sessionId: id, cwd });
3253
3427
  let currentAttachment = this.#router.attachment(id);
3254
3428
  for (let attempt = 0; !currentAttachment && attempt < 40; attempt++) {
@@ -3316,6 +3490,8 @@ export class AcpAgent implements Agent {
3316
3490
  backgroundBusy: retainedAbortOwner !== undefined,
3317
3491
  backgroundAnonymousCount: 0,
3318
3492
  backgroundCorrelations: retainedAbortOwner?.correlation ? [retainedAbortOwner.correlation] : [],
3493
+ sdkIdle: retainedAbortOwner === undefined,
3494
+ idleWaiters: new Set(),
3319
3495
  toolArgs: new Map(),
3320
3496
  uncertainAbortOwner: retainedAbortOwner,
3321
3497
  };
@@ -4006,6 +4182,17 @@ export class AcpAgent implements Agent {
4006
4182
  };
4007
4183
  record.pendingFirstPromptRetry = undefined;
4008
4184
  }
4185
+ const admissionReservation = record.pendingPromptAdmission;
4186
+ if (admissionReservation) {
4187
+ admissionReservation.settlement ??= voluntary
4188
+ ? { kind: "cancelled" }
4189
+ : {
4190
+ kind: "rejected",
4191
+ error: new AcpSdkAdapterError("connection_closed", `ACP session was ${reason}.`),
4192
+ };
4193
+ admissionReservation.wake();
4194
+ record.pendingPromptAdmission = undefined;
4195
+ }
4009
4196
  }
4010
4197
 
4011
4198
  const failures: unknown[] = [];
@@ -4059,6 +4246,11 @@ export class AcpAgent implements Agent {
4059
4246
  this.#rememberSettledPromptCorrelation(id, record, waiter.correlation);
4060
4247
  this.#fenceRetiredPromptAcknowledgement(id, waiter);
4061
4248
  }
4249
+ if (record.pendingPromptAdmission) {
4250
+ record.pendingPromptAdmission.settlement = { kind: "rejected", error };
4251
+ record.pendingPromptAdmission.wake();
4252
+ record.pendingPromptAdmission = undefined;
4253
+ }
4062
4254
  waiter?.reject(error);
4063
4255
  try {
4064
4256
  await adapter.close();
@@ -4128,12 +4320,14 @@ export class AcpAgent implements Agent {
4128
4320
  #observeSessionActivity(record: SessionRecord, frame: JsonObject): void {
4129
4321
  if (frame.type === "activity") {
4130
4322
  if (frame.state === "busy") {
4323
+ record.sdkIdle = false;
4131
4324
  record.busy = true;
4132
4325
  if (!record.activePrompt) {
4133
4326
  record.backgroundBusy = true;
4134
4327
  record.backgroundAnonymousCount = Math.max(record.backgroundAnonymousCount, 1);
4135
4328
  }
4136
4329
  } else if (frame.state === "idle") {
4330
+ record.sdkIdle = true;
4137
4331
  if (record.backgroundAnonymousCount > 0 || record.backgroundCorrelations.length > 0) {
4138
4332
  record.busy = true;
4139
4333
  record.backgroundBusy = true;
@@ -4143,6 +4337,8 @@ export class AcpAgent implements Agent {
4143
4337
  record.backgroundBusy = false;
4144
4338
  record.backgroundAnonymousCount = 0;
4145
4339
  record.backgroundCorrelations.length = 0;
4340
+ for (const resolve of record.idleWaiters) resolve();
4341
+ record.idleWaiters.clear();
4146
4342
  }
4147
4343
  return;
4148
4344
  }
@@ -4176,6 +4372,20 @@ export class AcpAgent implements Agent {
4176
4372
  }
4177
4373
  }
4178
4374
 
4375
+ /** Wait for the SDK's winding-down turn to publish idle before retrying a fresh prompt. */
4376
+ #waitForSessionIdle(record: SessionRecord): Promise<boolean> {
4377
+ if (record.sdkIdle && !record.backgroundBusy) return Promise.resolve(true);
4378
+ const deferred = Promise.withResolvers<void>();
4379
+ record.idleWaiters.add(deferred.resolve);
4380
+ const cancel = this.#promptWatchdogClock.schedule(deferred.resolve, ACP_BUSY_SETTLE_WAIT_MS);
4381
+ return deferred.promise
4382
+ .then(() => record.sdkIdle && !record.backgroundBusy)
4383
+ .finally(() => {
4384
+ record.idleWaiters.delete(deferred.resolve);
4385
+ cancel();
4386
+ });
4387
+ }
4388
+
4179
4389
  #frameProcessingFailure(error: unknown): AcpSdkAdapterError {
4180
4390
  if (error instanceof AcpSdkAdapterError && error.code === "frame_processing_failed") return error;
4181
4391
  const detail = error instanceof Error ? error.message : String(error);
@@ -5128,6 +5338,78 @@ export class AcpAgent implements Agent {
5128
5338
  }
5129
5339
  }
5130
5340
 
5341
+ /**
5342
+ * Wait for the model to settle when no explicit --model was passed but a default might be configured.
5343
+ * Give each probe its own timeout clamped to the remaining budget so wedged probes cannot exceed 500ms total.
5344
+ * Catch probe errors as advisory so a rejected probe does not discard the session (issue #6009).
5345
+ */
5346
+ async #waitForModelSettle(id: string): Promise<void> {
5347
+ const record = this.#sessions.get(id);
5348
+ if (!record) return; // Session was closed; skip settlement wait.
5349
+
5350
+ // If an explicit --model was passed, it's already settled by applyAcpStartupOptions.
5351
+ if (this.#startupOptions?.modelId) return;
5352
+
5353
+ // Set the deadline once for all probes (including the first).
5354
+ const deadline = Date.now() + ACP_MODEL_SETTLEMENT_TIMEOUT_MS;
5355
+
5356
+ // Query the config once to check if the model is already settled.
5357
+ // Even the first probe gets its own timeout clamped to the remaining budget.
5358
+ let config: unknown;
5359
+ const remaining = deadline - Date.now();
5360
+ if (remaining > 0) {
5361
+ try {
5362
+ const controller = new AbortController();
5363
+ const timeoutHandle = setTimeout(() => controller.abort(), remaining);
5364
+ try {
5365
+ const { promise: timeoutPromise, reject: rejectTimeout } = Promise.withResolvers<never>();
5366
+ const listener = () => rejectTimeout(new Error("probe_timeout"));
5367
+ controller.signal.addEventListener("abort", listener);
5368
+ config = await Promise.race([record.adapter.query("config.list/get"), timeoutPromise]);
5369
+ } finally {
5370
+ clearTimeout(timeoutHandle);
5371
+ }
5372
+ } catch {
5373
+ // Advisory: ignore first probe error and proceed to retry loop.
5374
+ // The first probe's failure doesn't mean the model won't settle later.
5375
+ config = undefined;
5376
+ }
5377
+ }
5378
+ const currentModel = config !== undefined ? configValues(config).get(MODEL_CONFIG_ID) : undefined;
5379
+ if (currentModel !== undefined) return; // Model is settled.
5380
+ if (remaining <= 0) return; // Budget exhausted before entering retry loop.
5381
+
5382
+ // No explicit model and no settled value: the SDK may be applying a default.
5383
+ // Retry with a bounded timeout, where each probe gets its own timeout clamped to the remaining budget.
5384
+ // Only wait if there's a reasonable chance a default is configured (no model after first probe).
5385
+ while (Date.now() < deadline) {
5386
+ await Bun.sleep(50); // Small delay before retry.
5387
+ const remaining = deadline - Date.now();
5388
+ if (remaining <= 0) break; // Budget exhausted.
5389
+
5390
+ let nextConfig: unknown;
5391
+ try {
5392
+ // Give this probe its own timeout clamped to the remaining budget.
5393
+ // A wedged probe will timeout after `remaining` ms, not the full SDK timeout.
5394
+ const controller = new AbortController();
5395
+ const timeoutHandle = setTimeout(() => controller.abort(), remaining);
5396
+ try {
5397
+ const { promise: timeoutPromise, reject: rejectTimeout } = Promise.withResolvers<never>();
5398
+ const listener = () => rejectTimeout(new Error("probe_timeout"));
5399
+ controller.signal.addEventListener("abort", listener);
5400
+ nextConfig = await Promise.race([record.adapter.query("config.list/get"), timeoutPromise]);
5401
+ } finally {
5402
+ clearTimeout(timeoutHandle);
5403
+ }
5404
+ } catch {
5405
+ // Advisory: probe failed or timed out; continue retrying while budget remains.
5406
+ continue;
5407
+ }
5408
+ if (configValues(nextConfig).get(MODEL_CONFIG_ID) !== undefined) return; // Settled.
5409
+ }
5410
+ // Budget exhausted: proceed without model. The model option may be missing from the response.
5411
+ }
5412
+
5131
5413
  async #sessionState(
5132
5414
  id: string,
5133
5415
  rejectUnavailableStartupPreset = false,