@gajae-code/coding-agent 0.12.19 → 0.12.20

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 (97) hide show
  1. package/CHANGELOG.md +20 -9
  2. package/dist/types/commands/gc.d.ts +4 -0
  3. package/dist/types/commands/sdk.d.ts +66 -0
  4. package/dist/types/config/settings-schema.d.ts +34 -0
  5. package/dist/types/gjc-runtime/gc-runtime.d.ts +97 -1
  6. package/dist/types/modes/acp/acp-agent.d.ts +33 -1
  7. package/dist/types/modes/components/bash-execution.d.ts +7 -0
  8. package/dist/types/modes/components/eval-execution.d.ts +7 -0
  9. package/dist/types/modes/utils/ui-helpers.d.ts +10 -1
  10. package/dist/types/sdk/acp/adapter.d.ts +9 -17
  11. package/dist/types/sdk/acp/index.d.ts +1 -1
  12. package/dist/types/sdk/broker/broker.d.ts +93 -1
  13. package/dist/types/sdk/broker/ensure.d.ts +17 -0
  14. package/dist/types/sdk/broker/index.d.ts +1 -0
  15. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +10 -1
  16. package/dist/types/sdk/broker/lifecycle.d.ts +81 -2
  17. package/dist/types/sdk/broker/startup-budget.d.ts +44 -0
  18. package/dist/types/sdk/bus/chat-daemon-control.d.ts +7 -1
  19. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +11 -1
  20. package/dist/types/sdk/bus/discord-daemon.d.ts +1 -0
  21. package/dist/types/sdk/bus/index.d.ts +20 -9
  22. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +2 -6
  23. package/dist/types/sdk/bus/slack-daemon.d.ts +2 -2
  24. package/dist/types/sdk/host/query/revision-store.d.ts +5 -0
  25. package/dist/types/sdk/host/session-runtime.d.ts +4 -0
  26. package/dist/types/sdk/lifecycle-session.d.ts +13 -2
  27. package/dist/types/sdk/prompt-failure.d.ts +13 -0
  28. package/dist/types/sdk/prompt-watchdog.d.ts +82 -0
  29. package/dist/types/sdk/session-reconnect.d.ts +20 -0
  30. package/dist/types/sdk/startup-capability.d.ts +1 -1
  31. package/dist/types/session/agent-session.d.ts +14 -4
  32. package/dist/types/session/blob-store.d.ts +32 -0
  33. package/dist/types/session/internal/managed-session-storage.d.ts +40 -2
  34. package/dist/types/session/internal/session-memory-sidecar.d.ts +881 -0
  35. package/dist/types/session/session-manager.d.ts +301 -27
  36. package/dist/types/session/session-storage.d.ts +170 -0
  37. package/dist/types/task/executor.d.ts +2 -1
  38. package/dist/types/tools/bash.d.ts +1 -0
  39. package/dist/types/tui/output-block.d.ts +6 -0
  40. package/package.json +8 -8
  41. package/src/cli/args.ts +75 -34
  42. package/src/commands/acp.ts +7 -1
  43. package/src/commands/gc.ts +31 -2
  44. package/src/commands/sdk.ts +141 -7
  45. package/src/config/settings-schema.ts +43 -0
  46. package/src/coordinator-mcp/server.ts +6 -1
  47. package/src/export/html/index.ts +83 -39
  48. package/src/gjc-runtime/gc-runtime.ts +1283 -4
  49. package/src/internal-urls/docs-index.generated.ts +1 -1
  50. package/src/internal-urls/local-protocol.ts +14 -0
  51. package/src/main.ts +40 -9
  52. package/src/modes/acp/acp-agent.ts +564 -37
  53. package/src/modes/acp/terminal-auth.ts +2 -2
  54. package/src/modes/components/bash-execution.ts +14 -0
  55. package/src/modes/components/eval-execution.ts +14 -0
  56. package/src/modes/controllers/command-controller.ts +63 -8
  57. package/src/modes/utils/ui-helpers.ts +81 -11
  58. package/src/sdk/acp/adapter.ts +46 -45
  59. package/src/sdk/acp/index.ts +1 -1
  60. package/src/sdk/broker/broker.ts +392 -11
  61. package/src/sdk/broker/ensure.ts +131 -61
  62. package/src/sdk/broker/index.ts +1 -0
  63. package/src/sdk/broker/lifecycle-ledger.ts +59 -5
  64. package/src/sdk/broker/lifecycle.ts +278 -63
  65. package/src/sdk/broker/startup-budget.ts +103 -0
  66. package/src/sdk/bus/chat-daemon-control.ts +9 -3
  67. package/src/sdk/bus/chat-daemon-runtime.ts +565 -29
  68. package/src/sdk/bus/conversation-store.ts +1 -1
  69. package/src/sdk/bus/discord-daemon.ts +2 -1
  70. package/src/sdk/bus/discord-live-provider.ts +34 -6
  71. package/src/sdk/bus/index.ts +118 -20
  72. package/src/sdk/bus/kind-aware-reconciliation.ts +2 -1
  73. package/src/sdk/bus/prompt-reconciliation.ts +4 -9
  74. package/src/sdk/bus/slack-daemon.ts +91 -18
  75. package/src/sdk/cli/session-cli.ts +8 -1
  76. package/src/sdk/host/query/handlers.ts +1 -1
  77. package/src/sdk/host/query/revision-store.ts +48 -12
  78. package/src/sdk/host/session-runtime.test.ts +50 -0
  79. package/src/sdk/host/session-runtime.ts +12 -2
  80. package/src/sdk/lifecycle-session.ts +26 -3
  81. package/src/sdk/mcp/server.ts +6 -1
  82. package/src/sdk/prompt-failure.ts +42 -0
  83. package/src/sdk/prompt-watchdog.ts +162 -0
  84. package/src/sdk/session-reconnect.ts +43 -0
  85. package/src/sdk/startup-capability.ts +13 -4
  86. package/src/session/agent-session.ts +525 -222
  87. package/src/session/blob-store.ts +89 -0
  88. package/src/session/internal/managed-session-scope.ts +36 -26
  89. package/src/session/internal/managed-session-storage.ts +303 -32
  90. package/src/session/internal/session-memory-sidecar.ts +2160 -0
  91. package/src/session/session-manager.ts +7027 -394
  92. package/src/session/session-storage.ts +1134 -11
  93. package/src/task/executor.ts +17 -4
  94. package/src/tools/bash.ts +109 -7
  95. package/src/tools/find.ts +55 -26
  96. package/src/tools/todo-contract.ts +40 -8
  97. package/src/tui/output-block.ts +11 -1
@@ -43,6 +43,7 @@ import {
43
43
  type AcpReverseConnection,
44
44
  AcpSdkAdapter,
45
45
  AcpSdkAdapterError,
46
+ acpMcpLaunchFailure,
46
47
  } from "../../sdk/acp";
47
48
  import { resolveAcpFinalText } from "../../sdk/acp/final-text";
48
49
  import { ACP_MCP_LIFECYCLE_TIMEOUT_MS, type SessionLifecycleMcpServer } from "../../sdk/acp/mcp";
@@ -50,6 +51,7 @@ import { ensureBroker } from "../../sdk/broker/ensure";
50
51
  import { readSdkBrokerDiscovery, SdkClient, SdkClientError } from "../../sdk/client";
51
52
  import { SYNTHETIC_PROVIDER_ID } from "../../sdk/model-profile-namespace";
52
53
  import type { SdkPromptTerminalOutcome } from "../../sdk/prompt-status";
54
+ import { PromptActivity, type PromptWatchdogClock, systemPromptWatchdogClock } from "../../sdk/prompt-watchdog";
53
55
  import {
54
56
  buildToolCallStartUpdate,
55
57
  mapAgentSessionEventToAcpSessionUpdates,
@@ -104,6 +106,16 @@ interface PromptWaiter {
104
106
  terminal?: { outcome: SdkPromptTerminalOutcome; correlation: PromptCorrelation };
105
107
  /** Frames for an already-settled correlation held until acknowledgement resolves ownership. */
106
108
  deferredFrames: JsonObject[];
109
+ /** Activity frames held until acknowledgement establishes exact prompt ownership. */
110
+ deferredActivityFrames: JsonObject[];
111
+ /** Clock reading of the last inbound frame for this prompt; baseline of the inactivity watchdog. */
112
+ lastFrameAt: number;
113
+ /** Frame (or wire event) type of that last frame, reported when the watchdog expires. */
114
+ lastFrameType: string;
115
+ /** Cancels the armed inactivity watchdog; re-armed by every inbound frame for this prompt. */
116
+ cancelWatchdog?: () => void;
117
+ /** What the host is observably doing — a tool running, a model call unanswered — and the bound that follows from it. */
118
+ activity: PromptActivity;
107
119
  resolve: (response: PromptResponse) => void;
108
120
  reject: (error: Error) => void;
109
121
  }
@@ -163,6 +175,14 @@ function parseAcpStartupOptions(value: unknown): AcpStartupOptions | undefined {
163
175
  : undefined;
164
176
  }
165
177
 
178
+ /** Tests inject a virtual clock so watchdog coverage never sleeps in real time. */
179
+ function parsePromptWatchdogClock(value: unknown): PromptWatchdogClock | undefined {
180
+ const candidate = object(value);
181
+ return typeof candidate?.now === "function" && typeof candidate.schedule === "function"
182
+ ? (candidate as unknown as PromptWatchdogClock)
183
+ : undefined;
184
+ }
185
+
166
186
  function object(value: unknown): JsonObject | undefined {
167
187
  return value !== null && typeof value === "object" && !Array.isArray(value) ? (value as JsonObject) : undefined;
168
188
  }
@@ -300,6 +320,15 @@ function hasCompleteCorrelation(correlation: PromptCorrelation): correlation is
300
320
  );
301
321
  }
302
322
 
323
+ function clearPromptWatchdog(waiter: PromptWaiter): void {
324
+ waiter.cancelWatchdog?.();
325
+ waiter.cancelWatchdog = undefined;
326
+ }
327
+
328
+ function describeCorrelation(correlation: PromptCorrelation): string {
329
+ return `commandId=${correlation.commandId ?? "none"} turnId=${correlation.turnId ?? "none"}`;
330
+ }
331
+
303
332
  function correlationsExactlyMatch(expected: PromptCorrelation, actual: PromptCorrelation): boolean {
304
333
  return (
305
334
  hasCompleteCorrelation(expected) &&
@@ -386,19 +415,106 @@ export interface TranscriptReplayContent {
386
415
  images: { available: false; reason: "historical_transcript_images_unavailable" };
387
416
  }
388
417
 
389
- export function transcriptReplayContent(entry: unknown): TranscriptReplayContent {
418
+ /** Machine-readable reason replay could not restore a transcript entry. */
419
+ export type TranscriptReplaySkipReason = "transcript_body_unavailable" | "transcript_tool_call_unavailable";
420
+
421
+ /**
422
+ * Replay decides per entry. An entry whose production body is missing is not
423
+ * replayable, and the caller reports that boundary instead of failing the whole
424
+ * load; fabricating an empty body would replay a message that never existed.
425
+ */
426
+ export type TranscriptReplayEntry =
427
+ | { replayable: true; content: TranscriptReplayContent }
428
+ | { replayable: false; reason: TranscriptReplaySkipReason };
429
+
430
+ export function transcriptReplayContent(entry: unknown): TranscriptReplayEntry {
390
431
  const record = object(entry);
391
- if (typeof record?.body !== "string")
392
- throw new AcpSdkAdapterError(
393
- "transcript_body_unavailable",
394
- "ACP cannot replay a transcript entry without its production body.",
395
- );
432
+ if (typeof record?.body !== "string") return { replayable: false, reason: "transcript_body_unavailable" };
396
433
  return {
397
- blocks: record.body.length > 0 ? [{ type: "text", text: record.body }] : [],
398
- images: { available: false, reason: "historical_transcript_images_unavailable" },
434
+ replayable: true,
435
+ content: {
436
+ blocks: record.body.length > 0 ? [{ type: "text", text: record.body }] : [],
437
+ images: { available: false, reason: "historical_transcript_images_unavailable" },
438
+ },
399
439
  };
400
440
  }
401
441
 
442
+ /**
443
+ * `transcript.list` answers an entry larger than one page with a body-less row
444
+ * `{ id, error: { code: "item_too_large" }, continuations }`. Each continuation is a
445
+ * `Q23` (`resource.body`) descriptor for one indexed string field of that entry, so
446
+ * the row is a pointer to the largest message in the session rather than a broken
447
+ * entry. Replay follows it instead of dropping the message.
448
+ */
449
+ export interface TranscriptContinuation {
450
+ query: string;
451
+ resourceKind: string;
452
+ resourceId: string;
453
+ revision: string;
454
+ itemId: string;
455
+ field: string;
456
+ }
457
+
458
+ /** Fields replay consumes; `textSummary` and the rest stay unread so recovery costs one query per used field. */
459
+ const RECOVERABLE_TRANSCRIPT_FIELDS = ["role", "body", "content", "toolCallId", "toolName", "isError"] as const;
460
+
461
+ /** Stands in for a tool result whose transcript entry replay could not restore. */
462
+ const TRANSCRIPT_TOOL_RESULT_UNAVAILABLE = "The transcript entry holding this tool result could not be replayed.";
463
+
464
+ /** Stands in for a tool call the transcript never recorded a result for. */
465
+ const TRANSCRIPT_TOOL_CALL_UNRESOLVED = "The session transcript ends without a result for this tool call.";
466
+
467
+ /** Stands in for a tool call replay abandoned before it could read back a result. */
468
+ const TRANSCRIPT_REPLAY_INTERRUPTED = "Transcript replay stopped before this tool call reached a result.";
469
+
470
+ /**
471
+ * The published tool calls a replay cleanup pass could not close, with the frame failures
472
+ * that refused them. `#replaySession` reads `unclosedToolCallIds` to name every stranded
473
+ * call in the `session/load` rejection, so a close that could not happen is reported to the
474
+ * caller instead of being discarded with the session record.
475
+ */
476
+ interface UnclosedReplayToolCalls {
477
+ unclosedToolCallIds: string[];
478
+ failures: unknown[];
479
+ }
480
+ function decodeTranscriptContinuation(field: string, body: string): unknown {
481
+ if (field !== "content" && field !== "isError") return body;
482
+ try {
483
+ const value: unknown = JSON.parse(body);
484
+ if (field === "content") return Array.isArray(value) ? value : undefined;
485
+ return typeof value === "boolean" ? value : undefined;
486
+ } catch {
487
+ return undefined;
488
+ }
489
+ }
490
+
491
+ export function transcriptContinuations(entry: unknown): TranscriptContinuation[] {
492
+ const record = object(entry);
493
+ if (!Array.isArray(record?.continuations)) return [];
494
+ const descriptors: TranscriptContinuation[] = [];
495
+ for (const value of record.continuations) {
496
+ const candidate = object(value);
497
+ if (
498
+ typeof candidate?.query !== "string" ||
499
+ typeof candidate.resourceKind !== "string" ||
500
+ typeof candidate.resourceId !== "string" ||
501
+ typeof candidate.revision !== "string" ||
502
+ typeof candidate.itemId !== "string" ||
503
+ typeof candidate.field !== "string"
504
+ )
505
+ continue;
506
+ descriptors.push({
507
+ query: candidate.query,
508
+ resourceKind: candidate.resourceKind,
509
+ resourceId: candidate.resourceId,
510
+ revision: candidate.revision,
511
+ itemId: candidate.itemId,
512
+ field: candidate.field,
513
+ });
514
+ }
515
+ return descriptors;
516
+ }
517
+
402
518
  type ReceivedSdkEvent = {
403
519
  event: JsonObject;
404
520
  /** Event payload accepted by the ACP event mapper, when this is an agent-wire frame. */
@@ -426,6 +542,16 @@ function receivedSdkEvent(frame: JsonObject): ReceivedSdkEvent | undefined {
426
542
  };
427
543
  }
428
544
 
545
+ /**
546
+ * Author of the message a `message_start`/`message_end` frame carries. The host echoes the
547
+ * user prompt and every tool result back through the same events, so only `"assistant"`
548
+ * proves a model call answered.
549
+ */
550
+ function frameMessageRole(event: JsonObject | undefined): string | undefined {
551
+ const role = object(event?.message)?.role;
552
+ return typeof role === "string" ? role : undefined;
553
+ }
554
+
429
555
  const ACP_CONFIG_OPTIONS = [
430
556
  { id: MODEL_CONFIG_ID, name: "Model", category: "model", options: [] },
431
557
  { id: THINKING_CONFIG_ID, name: "Thinking", category: "thought_level", options: [] },
@@ -889,12 +1015,20 @@ export class AcpAgent implements Agent {
889
1015
  #broker: Promise<BrokerConnection> | undefined;
890
1016
  readonly #startupOptions: AcpStartupOptions | undefined;
891
1017
  readonly #cancelSettlementGraceMs: number;
1018
+ readonly #promptWatchdogClock: PromptWatchdogClock;
892
1019
  #disposed = false;
893
1020
  #disposePromise: Promise<void> | undefined;
894
1021
 
895
1022
  constructor(
896
1023
  connection: AgentSideConnection,
897
- options?: { agentDir?: string; startupOptions?: AcpStartupOptions; cancelSettlementGraceMs?: number } | unknown,
1024
+ options?:
1025
+ | {
1026
+ agentDir?: string;
1027
+ startupOptions?: AcpStartupOptions;
1028
+ cancelSettlementGraceMs?: number;
1029
+ promptWatchdogClock?: PromptWatchdogClock;
1030
+ }
1031
+ | unknown,
898
1032
  ) {
899
1033
  this.#connection = connection;
900
1034
  const candidate = object(options);
@@ -905,6 +1039,7 @@ export class AcpAgent implements Agent {
905
1039
  Number.isSafeInteger(candidate.cancelSettlementGraceMs)
906
1040
  ? candidate.cancelSettlementGraceMs
907
1041
  : CANCEL_SETTLEMENT_GRACE_MS;
1042
+ this.#promptWatchdogClock = parsePromptWatchdogClock(candidate?.promptWatchdogClock) ?? systemPromptWatchdogClock;
908
1043
  queueMicrotask(() => {
909
1044
  if (connection.signal.aborted) {
910
1045
  this.#beginDispose();
@@ -1224,11 +1359,18 @@ export class AcpAgent implements Agent {
1224
1359
  emittedAssistantText: "",
1225
1360
  settled: false,
1226
1361
  deferredFrames: [],
1362
+ deferredActivityFrames: [],
1363
+ lastFrameAt: this.#promptWatchdogClock.now(),
1364
+ lastFrameType: "prompt_dispatch",
1365
+ activity: new PromptActivity(),
1227
1366
  resolve,
1228
1367
  reject,
1229
1368
  };
1230
1369
  record.activePrompt = waiter;
1231
1370
  });
1371
+ // Silence has to be bounded from the moment the prompt owns the session: a host that
1372
+ // dies before it ever answers is exactly the failure that leaves the client running.
1373
+ this.#armPromptWatchdog(params.sessionId, record, waiter);
1232
1374
  // Echo the user's own message back as `user_message_chunk`. Clients render their
1233
1375
  // transcript from session/update, so without this a prompt's text and any attached
1234
1376
  // image never appear in the client UI — only the agent's reply does. Replay
@@ -1263,6 +1405,13 @@ export class AcpAgent implements Agent {
1263
1405
  waiter.acknowledged = true;
1264
1406
  // Frames held while ownership was unknown belong to this prompt only when the
1265
1407
  // acknowledgement proves their complete correlation matches exactly.
1408
+ const deferredActivityFrames = waiter.deferredActivityFrames.splice(0);
1409
+ for (const deferredFrame of deferredActivityFrames) {
1410
+ const deferredEvent = receivedSdkEvent(deferredFrame)?.event;
1411
+ if (correlationsExactlyMatch(waiter.correlation, correlationFrom(deferredFrame, deferredEvent)))
1412
+ this.#observePromptActivity(waiter, deferredFrame);
1413
+ }
1414
+ if (deferredActivityFrames.length > 0) this.#armPromptWatchdog(params.sessionId, record, waiter);
1266
1415
  const deferred = waiter.deferredFrames.splice(0);
1267
1416
  for (const deferredFrame of deferred)
1268
1417
  if (correlationsExactlyMatch(waiter.correlation, correlationFrom(deferredFrame)))
@@ -1272,6 +1421,8 @@ export class AcpAgent implements Agent {
1272
1421
  this.#settlePrompt(record, waiter);
1273
1422
  } catch (error) {
1274
1423
  waiter.deferredFrames.length = 0;
1424
+ waiter.deferredActivityFrames.length = 0;
1425
+ clearPromptWatchdog(waiter);
1275
1426
  waiter.terminal = undefined;
1276
1427
  waiter.settled = true;
1277
1428
  if (record.activePrompt === waiter) record.activePrompt = undefined;
@@ -1326,9 +1477,11 @@ export class AcpAgent implements Agent {
1326
1477
  // when nothing settled the prompt the client already asked to cancel.
1327
1478
  if (this.#sessions.get(id) !== record || record.activePrompt !== waiter || waiter.settled) return;
1328
1479
  record.activePrompt = undefined;
1480
+ clearPromptWatchdog(waiter);
1329
1481
  record.cancelRequested = false;
1330
1482
  waiter.settled = true;
1331
1483
  waiter.deferredFrames.length = 0;
1484
+ waiter.deferredActivityFrames.length = 0;
1332
1485
  waiter.terminal = undefined;
1333
1486
  // A late terminal for this turn must stay closed rather than publish over a
1334
1487
  // prompt the client has already been told is cancelled.
@@ -1674,6 +1827,7 @@ export class AcpAgent implements Agent {
1674
1827
  // MUST catch these errors and return the semantically meaningful `cancelled`
1675
1828
  // stop reason." Involuntary teardown (transport loss) still rejects.
1676
1829
  if (waiter && !waiter.settled) {
1830
+ clearPromptWatchdog(waiter);
1677
1831
  if (reason === "closed" || reason === "discarded") {
1678
1832
  waiter.settled = true;
1679
1833
  waiter.resolve({ stopReason: "cancelled" });
@@ -1721,6 +1875,7 @@ export class AcpAgent implements Agent {
1721
1875
  record.reconnectUnsubscribe();
1722
1876
  const waiter = record.activePrompt;
1723
1877
  record.activePrompt = undefined;
1878
+ if (waiter) clearPromptWatchdog(waiter);
1724
1879
  waiter?.reject(error);
1725
1880
  try {
1726
1881
  await adapter.close();
@@ -1806,11 +1961,110 @@ export class AcpAgent implements Agent {
1806
1961
  return new AcpSdkAdapterError("frame_processing_failed", `ACP session frame processing failed: ${detail}`);
1807
1962
  }
1808
1963
 
1964
+ /**
1965
+ * Bounds how long one prompt may stay silent. A session host that stops producing
1966
+ * never publishes a terminal frame, and an ACP prompt only settles on a terminal, so
1967
+ * without this bound `session/prompt` never returns and the client reports the turn as
1968
+ * running forever behind a dead session.
1969
+ */
1970
+ #armPromptWatchdog(id: string, record: SessionRecord, waiter: PromptWaiter): void {
1971
+ if (waiter.settled) return;
1972
+ waiter.cancelWatchdog?.();
1973
+ // No frame can still arrive once the transport is known to be gone, so waiting out
1974
+ // the full bound would only add dead time to an outcome that is already decided.
1975
+ const delayMs = this.#promptTransportGone(id, record) ? 0 : waiter.activity.inactivityBoundMs;
1976
+ waiter.cancelWatchdog = this.#promptWatchdogClock.schedule(() => {
1977
+ void this.#expirePromptWatchdog(id, record, waiter);
1978
+ }, delayMs);
1979
+ }
1980
+
1981
+ /**
1982
+ * Any frame proves the producer is alive, so every one of them restarts the bound: liveness
1983
+ * is a property of the session host process, not of one turn, and a host still flushing an
1984
+ * abandoned turn's backlog is exactly a producer that has not stopped producing. Which bound
1985
+ * is restarted is a different question, and that one is correlation-owned; see below.
1986
+ */
1987
+ #refreshPromptWatchdog(id: string, record: SessionRecord, frame: JsonObject): void {
1988
+ const waiter = record.activePrompt;
1989
+ if (!waiter || waiter.settled) return;
1990
+ const event = receivedSdkEvent(frame)?.event;
1991
+ waiter.lastFrameAt = this.#promptWatchdogClock.now();
1992
+ waiter.lastFrameType =
1993
+ typeof event?.type === "string" ? event.type : typeof frame.type === "string" ? frame.type : "unknown";
1994
+ // Which bound applies is per-turn state, so it is fenced by the same identity that gates
1995
+ // settlement: only a frame carrying exactly the acknowledged prompt's command/turn may
1996
+ // start, clear, or extend a bound. Without this fence an already-settled turn's flushed
1997
+ // frames mutate the live turn — a stale `message_update` clears `awaitingModel` and
1998
+ // narrows it off the inference bound, a stale `tool_execution_start` pins it to the tool
1999
+ // bound with no tool running. Frames with no correlation at all (heartbeats,
2000
+ // session-scoped events) are refresh-only for the same reason: they prove the producer
2001
+ // lives but say nothing about what this turn is doing, so they leave the bound as is.
2002
+ // Before acknowledgement the prompt has no identity, so correlated activity is retained
2003
+ // and folded in only after the acknowledgement proves exact ownership.
2004
+ if (!waiter.acknowledged) {
2005
+ if (hasCorrelation(correlationFrom(frame, event))) waiter.deferredActivityFrames.push(frame);
2006
+ } else if (correlationsExactlyMatch(waiter.correlation, correlationFrom(frame, event))) {
2007
+ this.#observePromptActivity(waiter, frame);
2008
+ }
2009
+ this.#armPromptWatchdog(id, record, waiter);
2010
+ }
2011
+
2012
+ #observePromptActivity(waiter: PromptWaiter, frame: JsonObject): void {
2013
+ const event = receivedSdkEvent(frame)?.event;
2014
+ waiter.activity.observe(
2015
+ typeof event?.type === "string" ? event.type : undefined,
2016
+ typeof event?.toolCallId === "string" ? event.toolCallId : undefined,
2017
+ frameMessageRole(event),
2018
+ );
2019
+ }
2020
+
2021
+ /** Names why the prompt can be settled at once instead of waiting out the inactivity bound. */
2022
+ #promptTransportGone(id: string, record: SessionRecord): string | undefined {
2023
+ if (this.#disposed || this.#connection.signal.aborted) return "the ACP client connection is closed";
2024
+ if (this.#sessions.get(id) !== record) return "the SDK session host record was already discarded";
2025
+ return undefined;
2026
+ }
2027
+
2028
+ /**
2029
+ * Settles the ACP prompt only. The agent's own work is left alone: this reports that the
2030
+ * turn can no longer be observed, it does not cancel or tear down the session.
2031
+ */
2032
+ async #expirePromptWatchdog(id: string, record: SessionRecord, waiter: PromptWaiter): Promise<void> {
2033
+ if (record.activePrompt !== waiter || waiter.settled) return;
2034
+ const silenceMs = Math.max(0, this.#promptWatchdogClock.now() - waiter.lastFrameAt);
2035
+ const cause = this.#promptTransportGone(id, record) ?? "the SDK session host stopped producing frames";
2036
+ logger.error("acp_prompt_watchdog_expired", {
2037
+ sessionId: id,
2038
+ cause,
2039
+ silenceMs,
2040
+ lastFrameType: waiter.lastFrameType,
2041
+ inactivityBoundMs: waiter.activity.inactivityBoundMs,
2042
+ toolRunning: waiter.activity.running,
2043
+ awaitingModel: waiter.activity.awaitingModel,
2044
+ ...(waiter.correlation.commandId ? { commandId: waiter.correlation.commandId } : {}),
2045
+ ...(waiter.correlation.turnId ? { turnId: waiter.correlation.turnId } : {}),
2046
+ });
2047
+ await this.#rejectPrompt(
2048
+ record,
2049
+ id,
2050
+ waiter,
2051
+ new AcpSdkAdapterError(
2052
+ "prompt_abandoned",
2053
+ `ACP prompt was abandoned after ${Math.round(silenceMs / 1_000)}s of silence: ${cause}. Last frame was ` +
2054
+ `"${waiter.lastFrameType}" (${describeCorrelation(waiter.correlation)}). The turn was settled so the ` +
2055
+ `client stops waiting; the session still accepts the next prompt.`,
2056
+ ),
2057
+ );
2058
+ }
2059
+
1809
2060
  #enqueueSdkFrame(id: string, adapter: AcpSdkAdapter, frame: JsonObject): void {
1810
2061
  const record = this.#sessions.get(id);
1811
2062
  if (!record || record.adapter !== adapter) return;
1812
2063
  // Ingress ordering is recorded before queued work begins.
1813
2064
  this.#observeSessionActivity(record, frame);
2065
+ // Any frame at all proves the producer is still alive, so it refreshes the
2066
+ // inactivity watchdog before the queued work that may take a long time to run.
2067
+ this.#refreshPromptWatchdog(id, record, frame);
1814
2068
  ++record.inboundSequence;
1815
2069
  const task = record.frameTail.then(async () => await this.#handleSdkFrame(id, adapter, frame));
1816
2070
  record.frameTail = task.catch(
@@ -1828,6 +2082,7 @@ export class AcpAgent implements Agent {
1828
2082
  const waiter = record.activePrompt;
1829
2083
  if (waiter && !waiter.settled && !waiter.terminal) {
1830
2084
  record.activePrompt = undefined;
2085
+ clearPromptWatchdog(waiter);
1831
2086
  waiter.settled = true;
1832
2087
  if (hasCorrelation(waiter.correlation)) {
1833
2088
  record.settledPromptCorrelations.push(waiter.correlation);
@@ -1963,11 +2218,15 @@ export class AcpAgent implements Agent {
1963
2218
  });
1964
2219
  }
1965
2220
  }
1966
- await this.#emitEndOfTurnUpdates(id, adapter);
1967
- } else if (event.type === "agent_failed") {
1968
- await this.#emitEndOfTurnUpdates(id, adapter);
1969
2221
  }
2222
+ // The terminal frame is the turn's end, so it settles the prompt before anything
2223
+ // else is asked of the session host. `#emitEndOfTurnUpdates` queries `context.get`
2224
+ // and `session.metadata`, and a host that stops producing the moment it publishes
2225
+ // its terminal answers neither: sequencing settlement behind those advisory
2226
+ // queries is what left a finished turn reported as running until the inactivity
2227
+ // watchdog rescued it.
1970
2228
  if (activePrompt) this.#settlePrompt(record, activePrompt);
2229
+ if (event.type === "agent_end" || event.type === "agent_failed") await this.#emitEndOfTurnUpdates(id, adapter);
1971
2230
  }
1972
2231
 
1973
2232
  async #rejectPrompt(
@@ -1978,8 +2237,10 @@ export class AcpAgent implements Agent {
1978
2237
  ): Promise<void> {
1979
2238
  if (record.activePrompt !== waiter || waiter.settled) return;
1980
2239
  record.activePrompt = undefined;
2240
+ clearPromptWatchdog(waiter);
1981
2241
  waiter.settled = true;
1982
2242
  waiter.deferredFrames.length = 0;
2243
+ waiter.deferredActivityFrames.length = 0;
1983
2244
  waiter.terminal = undefined;
1984
2245
  if (hasCompleteCorrelation(waiter.correlation)) {
1985
2246
  record.settledPromptCorrelations.push(waiter.correlation);
@@ -2024,6 +2285,7 @@ export class AcpAgent implements Agent {
2024
2285
  return;
2025
2286
  }
2026
2287
  record.activePrompt = undefined;
2288
+ clearPromptWatchdog(waiter);
2027
2289
  waiter.settled = true;
2028
2290
  if (hasCorrelation(waiter.correlation)) {
2029
2291
  record.settledPromptCorrelations.push(waiter.correlation);
@@ -2057,6 +2319,9 @@ export class AcpAgent implements Agent {
2057
2319
  } catch {
2058
2320
  // Session naming is advisory; prompt completion remains authoritative.
2059
2321
  }
2322
+ // The prompt settled before these queries were asked, so a host that answers late
2323
+ // must not report the session idle after the next turn has already started.
2324
+ if (this.#sessions.get(id)?.activePrompt) return;
2060
2325
  if (typeof usage?.tokens === "number" && typeof usage.contextWindow === "number") {
2061
2326
  await this.#publishSessionUpdate(
2062
2327
  id,
@@ -2093,6 +2358,9 @@ export class AcpAgent implements Agent {
2093
2358
  notification: SessionNotification,
2094
2359
  expectedAdapter?: AcpSdkAdapter,
2095
2360
  ): Promise<void> {
2361
+ // A session that is gone has no update channel, so this is a drop and not a failure:
2362
+ // whoever owned the frame is the one that ended the session. Callers whose bookkeeping
2363
+ // assumes delivery have to end at the session too — see `#replaySession`.
2096
2364
  const record = this.#sessions.get(id);
2097
2365
  if (!record || (expectedAdapter && record.adapter !== expectedAdapter)) return;
2098
2366
  try {
@@ -2157,21 +2425,247 @@ export class AcpAgent implements Agent {
2157
2425
  );
2158
2426
  }
2159
2427
 
2428
+ /**
2429
+ * Reassembles a body-less `item_too_large` entry from the `continuations` the
2430
+ * producer already published, reading each field replay consumes through the same
2431
+ * `resource.body` query the descriptor names. Partial recovery is retained so a
2432
+ * failed body continuation cannot discard tool-call identity needed to close an
2433
+ * already-published pending call.
2434
+ */
2435
+ async #recoverTranscriptEntry(adapter: AcpSdkAdapter, entry: JsonObject): Promise<JsonObject> {
2436
+ const continuations = transcriptContinuations(entry);
2437
+ const recovered: JsonObject = { ...entry };
2438
+ for (const field of RECOVERABLE_TRANSCRIPT_FIELDS) {
2439
+ const continuation = continuations.find(candidate => candidate.field === field);
2440
+ if (!continuation) continue;
2441
+ const body = await this.#readContinuation(adapter, continuation);
2442
+ const value = body === undefined ? undefined : decodeTranscriptContinuation(field, body);
2443
+ // A row that advertises `isError` but will not yield it leaves the outcome
2444
+ // unproven, so replay reports failure rather than claiming a success it cannot
2445
+ // read back. A row that never advertised the field simply had no error flag.
2446
+ if (value === undefined) {
2447
+ if (field === "isError") recovered.isError = true;
2448
+ continue;
2449
+ }
2450
+ recovered[field] = value;
2451
+ }
2452
+ return recovered;
2453
+ }
2454
+
2455
+ /** Follows one continuation to its end, joining every page of that field's bytes. */
2456
+ async #readContinuation(adapter: AcpSdkAdapter, continuation: TranscriptContinuation): Promise<string | undefined> {
2457
+ const chunks: string[] = [];
2458
+ let cursor: string | undefined;
2459
+ for (let pageCount = 0; pageCount < MAX_ACP_REPLAY_PAGES; pageCount++) {
2460
+ let response: JsonObject | undefined;
2461
+ try {
2462
+ response = object(
2463
+ await adapter.query(
2464
+ continuation.query,
2465
+ cursor
2466
+ ? {}
2467
+ : {
2468
+ resourceKind: continuation.resourceKind,
2469
+ resourceId: continuation.resourceId,
2470
+ revision: continuation.revision,
2471
+ itemId: continuation.itemId,
2472
+ field: continuation.field,
2473
+ },
2474
+ cursor,
2475
+ ),
2476
+ );
2477
+ } catch {
2478
+ // A continuation that cannot be read costs one entry, never the whole
2479
+ // `session/load`; the caller reports the boundary instead.
2480
+ return undefined;
2481
+ }
2482
+ const result = object(response?.result) ?? response;
2483
+ const page = object(result?.page);
2484
+ const chunk = object(Array.isArray(page?.items) ? page.items[0] : undefined);
2485
+ if (typeof chunk?.body !== "string") return undefined;
2486
+ chunks.push(chunk.body);
2487
+ cursor = typeof page?.continuationCursor === "string" ? page.continuationCursor : undefined;
2488
+ if (!cursor) return chunks.join("");
2489
+ }
2490
+ return undefined;
2491
+ }
2492
+
2493
+ /**
2494
+ * Publishes the terminal update for one replayed tool call and reports whether the client
2495
+ * took it. A `toolResult` replay cannot use leaves its already-published `tool_call` at
2496
+ * `status: "pending"` forever, so replay closes it with the same `tool_execution_end`
2497
+ * shape a real result uses. Replay cannot know the outcome, so it reports failure rather
2498
+ * than claiming a success it cannot prove.
2499
+ *
2500
+ * Publication runs straight off the connection instead of through
2501
+ * {@link AcpAgent.#publishSessionUpdate}: that helper fails the session and deletes its
2502
+ * record on the first rejected frame, which turns every later close into a silent no-op
2503
+ * and would strand every call queued behind the first refusal. The direct path still
2504
+ * checks session ownership before every frame so a concurrent `session/close` stops it.
2505
+ */
2506
+ async #closeReplayToolCall(
2507
+ id: string,
2508
+ expectedAdapter: AcpSdkAdapter,
2509
+ cwd: string,
2510
+ toolCallId: string,
2511
+ tool: { name: string; args: unknown },
2512
+ reason: string,
2513
+ ): Promise<AcpSdkAdapterError | undefined> {
2514
+ try {
2515
+ for (const notification of mapAgentSessionEventToAcpSessionUpdates(
2516
+ {
2517
+ type: "tool_execution_end",
2518
+ toolCallId,
2519
+ toolName: tool.name,
2520
+ result: { content: [{ type: "text", text: reason }] },
2521
+ isError: true,
2522
+ } as never,
2523
+ id,
2524
+ { cwd, getToolArgs: () => tool.args },
2525
+ )) {
2526
+ const record = this.#sessions.get(id);
2527
+ if (!record || record.adapter !== expectedAdapter) return undefined;
2528
+ await this.#connection.sessionUpdate(notification);
2529
+ }
2530
+ return undefined;
2531
+ } catch (error) {
2532
+ return this.#frameProcessingFailure(error);
2533
+ }
2534
+ }
2535
+
2536
+ /**
2537
+ * Closes every tool call still open when replay stops, in a single pass, and hands back
2538
+ * the ones the client refused.
2539
+ *
2540
+ * An entry leaves `replayTools` only once its close was accepted. Removing it on the
2541
+ * attempt is what made a refused close disappear from the map before anything could name
2542
+ * it, so the one call the report exists to surface was the one call it never mentioned.
2543
+ */
2544
+ async #closeOpenReplayToolCalls(
2545
+ id: string,
2546
+ adapter: AcpSdkAdapter,
2547
+ cwd: string,
2548
+ replayTools: Map<string, { name: string; args: unknown }>,
2549
+ reason: string,
2550
+ ): Promise<UnclosedReplayToolCalls> {
2551
+ const unclosed: UnclosedReplayToolCalls = { unclosedToolCallIds: [], failures: [] };
2552
+ for (const [toolCallId, tool] of [...replayTools]) {
2553
+ const failure = await this.#closeReplayToolCall(id, adapter, cwd, toolCallId, tool, reason);
2554
+ if (failure === undefined) {
2555
+ replayTools.delete(toolCallId);
2556
+ continue;
2557
+ }
2558
+ unclosed.unclosedToolCallIds.push(toolCallId);
2559
+ unclosed.failures.push(failure);
2560
+ }
2561
+ return unclosed;
2562
+ }
2563
+
2160
2564
  async #replaySession(id: string): Promise<void> {
2161
2565
  const adapter = this.#adapter(id);
2162
2566
  const record = this.#sessions.get(id);
2163
2567
  if (!record) return;
2568
+ const replayTools = new Map<string, { name: string; args: unknown }>();
2569
+ let replayFailure: unknown;
2570
+ try {
2571
+ await this.#replayTranscriptPages(id, adapter, record, replayTools);
2572
+ } catch (error) {
2573
+ replayFailure = error;
2574
+ }
2575
+ // Every terminal below is addressed to this session, and `session/close`, a failed
2576
+ // session, or connection teardown can remove it mid-replay. That removal takes the
2577
+ // client's view of these calls with it: nothing is left to observe a `pending` one,
2578
+ // and a frame carrying a closed session id is one the client asked to stop receiving.
2579
+ // The obligation ends where the session ends; the next `session/load` replays the same
2580
+ // transcript rows from scratch. The boundary check avoids entering cleanup for an
2581
+ // already-closed session, while `#closeReplayToolCall` repeats the ownership check before
2582
+ // each direct publication so a close during cleanup stops the remaining frames without
2583
+ // restoring the failure side effects that used to silence calls behind a refused close.
2584
+ if (this.#sessions.get(id)?.adapter !== adapter) return;
2585
+ // The one boundary that closes replayed tool calls, covering every way the replay body
2586
+ // can end: normal return, early exit, or a `transcript.list` page that throws. Whatever
2587
+ // is still open was abandoned mid-replay, so it reaches a terminal status now instead of
2588
+ // spinning at `pending` for the life of the session.
2589
+ const unclosed = await this.#closeOpenReplayToolCalls(
2590
+ id,
2591
+ adapter,
2592
+ record.cwd,
2593
+ replayTools,
2594
+ replayFailure === undefined ? TRANSCRIPT_TOOL_CALL_UNRESOLVED : TRANSCRIPT_REPLAY_INTERRUPTED,
2595
+ );
2596
+ if (unclosed.unclosedToolCallIds.length === 0) {
2597
+ if (replayFailure !== undefined) throw replayFailure;
2598
+ return;
2599
+ }
2600
+ const cleanupFailure = aggregateAcpFailure(
2601
+ "frame_processing_failed",
2602
+ `ACP transcript replay could not close published tool calls: ${unclosed.unclosedToolCallIds.join(", ")}`,
2603
+ unclosed.failures,
2604
+ );
2605
+ // A client refusing this session's frames leaves no usable record behind, but teardown
2606
+ // runs only after every open call has been attempted and every refusal named above.
2607
+ await this.#failSession(id, adapter, cleanupFailure);
2608
+ if (replayFailure === undefined) throw cleanupFailure;
2609
+ // Both facts matter: what stopped replay, and which calls it left open. Reporting
2610
+ // only one is what kept a whole session's orphaned tool calls invisible.
2611
+ const detail = [replayFailure, cleanupFailure]
2612
+ .map(failure => (failure instanceof Error ? failure.message : String(failure)))
2613
+ .join("; ");
2614
+ throw aggregateAcpFailure(
2615
+ "frame_processing_failed",
2616
+ `ACP transcript replay failed and left published tool calls unclosed: ${detail}`,
2617
+ [replayFailure, cleanupFailure],
2618
+ );
2619
+ }
2620
+
2621
+ /**
2622
+ * Walks the transcript pages for {@link AcpAgent.#replaySession}. `replayTools` stays
2623
+ * owned by the caller so every start this pass published is still closable once it
2624
+ * exits, however it exits.
2625
+ */
2626
+ async #replayTranscriptPages(
2627
+ id: string,
2628
+ adapter: AcpSdkAdapter,
2629
+ record: SessionRecord,
2630
+ replayTools: Map<string, { name: string; args: unknown }>,
2631
+ ): Promise<void> {
2164
2632
  let cursor: string | undefined;
2165
2633
  let imageLimitationReported = false;
2166
- const replayTools = new Map<string, { name: string; args: unknown }>();
2634
+ let unreplayableEntries = 0;
2635
+ let unreplayableReason: TranscriptReplaySkipReason | undefined;
2167
2636
  for (let pageCount = 0; pageCount < MAX_ACP_REPLAY_PAGES; pageCount++) {
2168
2637
  const response = object(await adapter.query("transcript.list", {}, cursor));
2169
2638
  const result = object(response?.result) ?? response;
2170
2639
  const page = object(result?.page);
2171
2640
  for (const item of Array.isArray(page?.items) ? page.items : []) {
2172
- const message = object(item);
2173
- if (!message) continue;
2174
- const content = transcriptReplayContent(message);
2641
+ const raw = object(item);
2642
+ if (!raw) continue;
2643
+ // A body-less row is usually an oversized message, not a malformed entry:
2644
+ // its `continuations` say exactly how to read it back, so replay follows
2645
+ // them instead of dropping the largest message in the session.
2646
+ const message = typeof raw.body === "string" ? raw : await this.#recoverTranscriptEntry(adapter, raw);
2647
+ const replay = transcriptReplayContent(message);
2648
+ if (!replay.replayable) {
2649
+ unreplayableEntries++;
2650
+ unreplayableReason ??= replay.reason;
2651
+ const unresolvedId = typeof message.toolCallId === "string" ? message.toolCallId : undefined;
2652
+ const unresolved = unresolvedId === undefined ? undefined : replayTools.get(unresolvedId);
2653
+ // The start keeps its place in `replayTools` until the close is accepted, so a
2654
+ // refused close is retried and named by the boundary instead of dropped here.
2655
+ if (unresolvedId !== undefined && unresolved) {
2656
+ const failure = await this.#closeReplayToolCall(
2657
+ id,
2658
+ adapter,
2659
+ record.cwd,
2660
+ unresolvedId,
2661
+ unresolved,
2662
+ TRANSCRIPT_TOOL_RESULT_UNAVAILABLE,
2663
+ );
2664
+ if (failure === undefined) replayTools.delete(unresolvedId);
2665
+ }
2666
+ continue;
2667
+ }
2668
+ const content = replay.content;
2175
2669
  if (!imageLimitationReported) {
2176
2670
  imageLimitationReported = true;
2177
2671
  await this.#publishSessionUpdate(
@@ -2250,8 +2744,35 @@ export class AcpAgent implements Agent {
2250
2744
  }
2251
2745
  if (message.role === "toolResult" && typeof message.toolCallId === "string") {
2252
2746
  const replayTool = replayTools.get(message.toolCallId);
2253
- const toolName = typeof message.toolName === "string" ? message.toolName : replayTool?.name;
2254
- if (!toolName) continue;
2747
+ // Pairing outranks content: publishing a result whose `tool_call` start
2748
+ // never reached the client renders an update for a call it never saw begin.
2749
+ if (!replayTool) {
2750
+ unreplayableEntries++;
2751
+ unreplayableReason ??= "transcript_tool_call_unavailable";
2752
+ continue;
2753
+ }
2754
+ // The start already named the call, so a result row that lost its own name
2755
+ // falls back to it rather than dropping a result the client can still place.
2756
+ const toolName =
2757
+ typeof message.toolName === "string" && message.toolName ? message.toolName : replayTool.name;
2758
+ // A start with no usable name anywhere still has to reach a terminal status:
2759
+ // skipping it here is what left the call spinning at `pending` forever.
2760
+ if (!toolName) {
2761
+ unreplayableEntries++;
2762
+ unreplayableReason ??= "transcript_tool_call_unavailable";
2763
+ // The start keeps its place in `replayTools` until the close is accepted, so a
2764
+ // refused close is retried and named by the boundary instead of dropped here.
2765
+ const failure = await this.#closeReplayToolCall(
2766
+ id,
2767
+ adapter,
2768
+ record.cwd,
2769
+ message.toolCallId,
2770
+ replayTool,
2771
+ TRANSCRIPT_TOOL_RESULT_UNAVAILABLE,
2772
+ );
2773
+ if (failure === undefined) replayTools.delete(message.toolCallId);
2774
+ continue;
2775
+ }
2255
2776
  const resultContent = richContent
2256
2777
  ?.map(object)
2257
2778
  .filter(
@@ -2292,7 +2813,30 @@ export class AcpAgent implements Agent {
2292
2813
  }
2293
2814
  }
2294
2815
  cursor = typeof page?.continuationCursor === "string" ? page.continuationCursor : undefined;
2295
- if (!cursor) return;
2816
+ if (cursor) continue;
2817
+ // Every start still parked here outlived its result row. `#replaySession` owns the
2818
+ // close at its single boundary, so this pass only accounts for them.
2819
+ if (replayTools.size > 0) {
2820
+ unreplayableEntries += replayTools.size;
2821
+ unreplayableReason ??= "transcript_tool_call_unavailable";
2822
+ }
2823
+ // An entry without its production body is skipped, never fatal: losing one
2824
+ // transcript row must not revoke `session/load` for the whole session.
2825
+ if (unreplayableReason)
2826
+ await this.#publishSessionUpdate(
2827
+ id,
2828
+ {
2829
+ sessionId: id,
2830
+ update: {
2831
+ sessionUpdate: "session_info_update",
2832
+ _meta: {
2833
+ gjcTranscriptReplaySkipped: { count: unreplayableEntries, reason: unreplayableReason },
2834
+ },
2835
+ },
2836
+ },
2837
+ adapter,
2838
+ );
2839
+ return;
2296
2840
  }
2297
2841
  throw new AcpSdkAdapterError("resource_exhausted", "ACP transcript replay exceeded the page limit.");
2298
2842
  }
@@ -2467,24 +3011,7 @@ export class AcpAgent implements Agent {
2467
3011
  try {
2468
3012
  return await (await this.#brokerAdapter()).global(operation, input, idempotencyKey);
2469
3013
  } catch (error) {
2470
- const code =
2471
- typeof error === "object" && error !== null && "code" in error && typeof error.code === "string"
2472
- ? error.code
2473
- : undefined;
2474
- if (
2475
- mcpServers.length === 0 ||
2476
- code === "invalid_input" ||
2477
- code === "authentication_failed" ||
2478
- code === "unknown_model_profile" ||
2479
- code === "model_profile_registry_error"
2480
- )
2481
- throw error;
2482
- const names = mcpServers
2483
- .slice(0, 8)
2484
- .map(server => server.name)
2485
- .join(", ");
2486
- const suffix = mcpServers.length > 8 ? `, and ${mcpServers.length - 8} more` : "";
2487
- throw new AcpSdkAdapterError("unavailable", `MCP server request failed to start (${names}${suffix}).`);
3014
+ throw acpMcpLaunchFailure(error, mcpServers);
2488
3015
  }
2489
3016
  }
2490
3017