@gajae-code/coding-agent 0.12.18 → 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 (100) hide show
  1. package/CHANGELOG.md +26 -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/bus/telegram-daemon-contract.d.ts +4 -2
  25. package/dist/types/sdk/host/query/revision-store.d.ts +5 -0
  26. package/dist/types/sdk/host/session-runtime.d.ts +4 -0
  27. package/dist/types/sdk/lifecycle-session.d.ts +13 -2
  28. package/dist/types/sdk/prompt-failure.d.ts +13 -0
  29. package/dist/types/sdk/prompt-watchdog.d.ts +82 -0
  30. package/dist/types/sdk/session-reconnect.d.ts +20 -0
  31. package/dist/types/sdk/startup-capability.d.ts +1 -1
  32. package/dist/types/session/agent-session.d.ts +14 -4
  33. package/dist/types/session/blob-store.d.ts +32 -0
  34. package/dist/types/session/internal/managed-session-storage.d.ts +40 -2
  35. package/dist/types/session/internal/session-memory-sidecar.d.ts +881 -0
  36. package/dist/types/session/session-manager.d.ts +301 -27
  37. package/dist/types/session/session-storage.d.ts +170 -0
  38. package/dist/types/task/executor.d.ts +2 -1
  39. package/dist/types/tools/bash.d.ts +1 -0
  40. package/dist/types/tui/output-block.d.ts +6 -0
  41. package/package.json +8 -8
  42. package/src/cli/args.ts +75 -34
  43. package/src/commands/acp.ts +7 -1
  44. package/src/commands/gc.ts +31 -2
  45. package/src/commands/sdk.ts +141 -7
  46. package/src/config/settings-schema.ts +43 -0
  47. package/src/coordinator-mcp/server.ts +6 -1
  48. package/src/export/html/index.ts +83 -39
  49. package/src/gjc-runtime/gc-runtime.ts +1283 -4
  50. package/src/internal-urls/docs-index.generated.ts +1 -1
  51. package/src/internal-urls/local-protocol.ts +14 -0
  52. package/src/main.ts +40 -9
  53. package/src/modes/acp/acp-agent.ts +564 -37
  54. package/src/modes/acp/terminal-auth.ts +2 -2
  55. package/src/modes/components/bash-execution.ts +14 -0
  56. package/src/modes/components/eval-execution.ts +14 -0
  57. package/src/modes/controllers/command-controller.ts +63 -8
  58. package/src/modes/utils/ui-helpers.ts +81 -11
  59. package/src/sdk/acp/adapter.ts +46 -45
  60. package/src/sdk/acp/index.ts +1 -1
  61. package/src/sdk/broker/broker.ts +392 -11
  62. package/src/sdk/broker/ensure.ts +131 -61
  63. package/src/sdk/broker/index.ts +1 -0
  64. package/src/sdk/broker/lifecycle-ledger.ts +59 -5
  65. package/src/sdk/broker/lifecycle.ts +278 -63
  66. package/src/sdk/broker/startup-budget.ts +103 -0
  67. package/src/sdk/bus/chat-daemon-control.ts +9 -3
  68. package/src/sdk/bus/chat-daemon-runtime.ts +565 -29
  69. package/src/sdk/bus/conversation-store.ts +1 -1
  70. package/src/sdk/bus/discord-daemon.ts +2 -1
  71. package/src/sdk/bus/discord-live-provider.ts +34 -6
  72. package/src/sdk/bus/index.ts +118 -20
  73. package/src/sdk/bus/kind-aware-reconciliation.ts +2 -1
  74. package/src/sdk/bus/prompt-reconciliation.ts +4 -9
  75. package/src/sdk/bus/slack-daemon.ts +91 -18
  76. package/src/sdk/bus/telegram-daemon-contract.ts +4 -2
  77. package/src/sdk/bus/topic-registry.ts +7 -1
  78. package/src/sdk/cli/session-cli.ts +8 -1
  79. package/src/sdk/host/query/handlers.ts +1 -1
  80. package/src/sdk/host/query/revision-store.ts +48 -12
  81. package/src/sdk/host/session-runtime.test.ts +50 -0
  82. package/src/sdk/host/session-runtime.ts +12 -2
  83. package/src/sdk/lifecycle-session.ts +26 -3
  84. package/src/sdk/mcp/server.ts +6 -1
  85. package/src/sdk/prompt-failure.ts +42 -0
  86. package/src/sdk/prompt-watchdog.ts +162 -0
  87. package/src/sdk/session-reconnect.ts +43 -0
  88. package/src/sdk/startup-capability.ts +13 -4
  89. package/src/session/agent-session.ts +525 -222
  90. package/src/session/blob-store.ts +89 -0
  91. package/src/session/internal/managed-session-scope.ts +36 -26
  92. package/src/session/internal/managed-session-storage.ts +303 -32
  93. package/src/session/internal/session-memory-sidecar.ts +2160 -0
  94. package/src/session/session-manager.ts +7027 -394
  95. package/src/session/session-storage.ts +1134 -11
  96. package/src/task/executor.ts +17 -4
  97. package/src/tools/bash.ts +109 -7
  98. package/src/tools/find.ts +55 -26
  99. package/src/tools/todo-contract.ts +40 -8
  100. package/src/tui/output-block.ts +11 -1
@@ -23,11 +23,11 @@ export function prepareAcpTerminalAuthArgs(rawArgs: readonly string[]): AcpTermi
23
23
  const args: string[] = [];
24
24
  for (let i = 0; i < withoutAuthFlag.length; i++) {
25
25
  const arg = withoutAuthFlag[i];
26
- if (arg === "--mode") {
26
+ if (arg === "--mode" && withoutAuthFlag[i + 1] === "acp") {
27
27
  i++;
28
28
  continue;
29
29
  }
30
- if (arg.startsWith("--mode=")) {
30
+ if (arg === "--mode=acp") {
31
31
  continue;
32
32
  }
33
33
  args.push(arg);
@@ -51,6 +51,7 @@ export class BashExecutionComponent extends Container {
51
51
  #chunkGate = false;
52
52
  #contentContainer: Container;
53
53
  #headerText: Text;
54
+ #resultPersisted = false;
54
55
 
55
56
  constructor(
56
57
  private readonly command: string,
@@ -259,4 +260,17 @@ export class BashExecutionComponent extends Container {
259
260
  getCommand(): string {
260
261
  return this.command;
261
262
  }
263
+
264
+ /**
265
+ * Record that this execution's message reached session state, so a transcript
266
+ * rebuild renders it from the session instead of keeping this live block parked.
267
+ */
268
+ markResultPersisted(): void {
269
+ this.#resultPersisted = true;
270
+ }
271
+
272
+ /** Whether the session already holds this execution's message. */
273
+ hasPersistedResult(): boolean {
274
+ return this.#resultPersisted;
275
+ }
262
276
  }
@@ -30,6 +30,7 @@ export class EvalExecutionComponent extends Container {
30
30
  #expanded = false;
31
31
  #contentContainer: Container;
32
32
  #headerText: Text;
33
+ #resultPersisted = false;
33
34
 
34
35
  #highlightLang(): "python" | "javascript" {
35
36
  return this.language === "js" ? "javascript" : "python";
@@ -165,4 +166,17 @@ export class EvalExecutionComponent extends Container {
165
166
  getCode(): string {
166
167
  return this.code;
167
168
  }
169
+
170
+ /**
171
+ * Record that this execution's message reached session state, so a transcript
172
+ * rebuild renders it from the session instead of keeping this live block parked.
173
+ */
174
+ markResultPersisted(): void {
175
+ this.#resultPersisted = true;
176
+ }
177
+
178
+ /** Whether the session already holds this execution's message. */
179
+ hasPersistedResult(): boolean {
180
+ return this.#resultPersisted;
181
+ }
168
182
  }
@@ -5,7 +5,7 @@ import { CompactionCancelledError, type CompactionOutcome } from "@gajae-code/ag
5
5
  import { getEnvApiKey, type ToolCall, type UsageLimit, type UsageReport } from "@gajae-code/ai/core";
6
6
  import type { ProviderDetails } from "@gajae-code/ai/provider-details";
7
7
  import { type Keybinding, Loader, Markdown, padding, Spacer, Text, visibleWidth } from "@gajae-code/tui";
8
- import { formatDuration, Snowflake, setProjectDir } from "@gajae-code/utils";
8
+ import { formatBytes, formatDuration, Snowflake, setProjectDir } from "@gajae-code/utils";
9
9
  import { resolveAppendOnlyMode } from "../../append-only-mode";
10
10
  import { jobElapsedMs } from "../../async";
11
11
  import { reset as resetCapabilities } from "../../capability";
@@ -39,7 +39,7 @@ import { getDisplayChangelogEntries } from "../../utils/changelog";
39
39
  import { copyToClipboard } from "../../utils/clipboard";
40
40
  import { openPath } from "../../utils/open";
41
41
  import { setSessionTerminalTitle } from "../../utils/title-generator";
42
- import { prepareTranscriptRebuild } from "../utils/ui-helpers";
42
+ import { addChatChild, prepareTranscriptRebuild, syncPendingExecutionComponents } from "../utils/ui-helpers";
43
43
 
44
44
  type HindsightModule = typeof import("../../hindsight");
45
45
  let hindsightModulePromise: Promise<HindsightModule> | undefined;
@@ -473,6 +473,45 @@ export class CommandController {
473
473
  const settingLabel = setting === "auto" ? `${setting} (${provider ?? "?"})` : setting;
474
474
  info += `${theme.fg("dim", "Append-Only:")} ${activeLabel} (setting: ${settingLabel})\n`;
475
475
  }
476
+ if (stats.sessionMemory) {
477
+ const memory = stats.sessionMemory;
478
+ const retirement = memory.coldRetirementActive ? theme.fg("success", "active") : theme.fg("dim", "inactive");
479
+ info += `\n${theme.bold("Session Memory")}\n`;
480
+ info += `${theme.fg("dim", "Cold Retirement:")} ${retirement}\n`;
481
+ info += `${theme.fg("dim", "Hot Region:")} ${formatBytes(memory.hotRegionBytes)}\n`;
482
+ info += `${theme.fg("dim", "Metadata:")} ${formatBytes(memory.metaDescriptorBytes)}\n`;
483
+ info += `${theme.fg("dim", "Accounted:")} ${formatBytes(memory.totalAccountedBytes)}\n`;
484
+ if (memory.coldIndexBytes > 0 || memory.coldIndexBlockCacheBytes > 0 || memory.coldEntryCacheBytes > 0) {
485
+ info += `${theme.fg("dim", "Cold Index/Caches:")} ${formatBytes(memory.coldIndexBytes)} / ${formatBytes(memory.coldIndexBlockCacheBytes)} / ${formatBytes(memory.coldEntryCacheBytes)}\n`;
486
+ }
487
+ if (memory.lazyReopenAttempted) {
488
+ const reopen = memory.lazyReopenSucceeded ? theme.fg("success", "exact") : theme.fg("warning", "fallback");
489
+ info += `${theme.fg("dim", "Lazy Reopen:")} ${reopen}\n`;
490
+ }
491
+ const fallbackReason = memory.retirementFallbackReason ?? memory.lazyReopenFallbackReason;
492
+ if (fallbackReason) info += `${theme.fg("dim", "Fallback Reason:")} ${fallbackReason}\n`;
493
+ if (memory.coldEntriesRetired > 0 || memory.coldEntriesReloaded > 0) {
494
+ info += `${theme.fg("dim", "Cold Retired/Reloaded:")} ${memory.coldEntriesRetired}/${memory.coldEntriesReloaded}\n`;
495
+ }
496
+ if (memory.rangeReadCount > 0 || memory.rangeReadGenerationMismatchCount > 0) {
497
+ info += `${theme.fg("dim", "Cold Range Reads:")} ${memory.rangeReadCount} (${memory.rangeReadGenerationMismatchCount} mismatched)\n`;
498
+ }
499
+ if (memory.sidecarRebuildCount > 0 || memory.transcriptGeneration > 0) {
500
+ info += `${theme.fg("dim", "Sidecar Rebuilds:")} ${memory.sidecarRebuildCount} (gen ${memory.transcriptGeneration})\n`;
501
+ }
502
+ if (memory.coldMutationPromotions > 0 || memory.hotOverflowTransitions > 0) {
503
+ info += `${theme.fg("dim", "Cold Promotions/Overflows:")} ${memory.coldMutationPromotions}/${memory.hotOverflowTransitions}\n`;
504
+ }
505
+ if (memory.labelDiskFallbackCount > 0) {
506
+ info += `${theme.fg("dim", "Label Disk Reads:")} ${memory.labelDiskFallbackCount}\n`;
507
+ }
508
+ if (memory.shadowParityCheckCount > 0) {
509
+ info += `${theme.fg("dim", "Shadow Parity:")} ${memory.shadowParityMismatchCount}/${memory.shadowParityCheckCount} mismatches\n`;
510
+ }
511
+ if (memory.autoDisabledReason) {
512
+ info += `${theme.fg("dim", "Auto-Disabled:")} ${theme.fg("warning", memory.autoDisabledReason)}\n`;
513
+ }
514
+ }
476
515
  info += `${theme.bold("Tokens")}\n`;
477
516
  info += `${theme.fg("dim", "Input:")} ${stats.tokens.input.toLocaleString()}\n`;
478
517
  info += `${theme.fg("dim", "Output:")} ${stats.tokens.output.toLocaleString()}\n`;
@@ -1147,7 +1186,8 @@ export class CommandController {
1147
1186
 
1148
1187
  async handleBashCommand(command: string, excludeFromContext = false): Promise<void> {
1149
1188
  const isDeferred = this.ctx.session.isStreaming;
1150
- this.ctx.bashComponent = new BashExecutionComponent(command, this.ctx.ui, excludeFromContext);
1189
+ const component = new BashExecutionComponent(command, this.ctx.ui, excludeFromContext);
1190
+ this.ctx.bashComponent = component;
1151
1191
 
1152
1192
  if (isDeferred) {
1153
1193
  this.ctx.pendingMessagesContainer.addChild(this.ctx.bashComponent);
@@ -1165,7 +1205,9 @@ export class CommandController {
1165
1205
  this.ctx.bashComponent.appendOutput(chunk);
1166
1206
  }
1167
1207
  },
1168
- { excludeFromContext },
1208
+ // A transcript rebuild racing this call must drop the parked block once the
1209
+ // session owns its message, otherwise the rebuilt row is rendered twice.
1210
+ { excludeFromContext, onPersisted: () => component.markResultPersisted() },
1169
1211
  );
1170
1212
 
1171
1213
  if (this.ctx.bashComponent) {
@@ -1182,8 +1224,18 @@ export class CommandController {
1182
1224
  this.ctx.showError(`Bash command failed: ${error instanceof Error ? error.message : "Unknown error"}`);
1183
1225
  }
1184
1226
  const bashComponent = this.ctx.bashComponent;
1185
- if (isDeferred && bashComponent && this.ctx.pendingBashComponents.includes(bashComponent)) {
1186
- this.ctx.pendingMessagesContainer.detachChild(bashComponent);
1227
+ if (isDeferred && bashComponent) {
1228
+ // Parentage is the container's answer, never this bookkeeping's: `/clear`,
1229
+ // `/context-clear`, extension redraws and the selector all call
1230
+ // `pendingMessagesContainer.clear()`, which disposes and evicts the parked
1231
+ // component while leaving it listed in `pendingBashComponents`. Ask the
1232
+ // container whether it still holds a live child before moving anything;
1233
+ // a disposed block is terminal and must never reach a fresh transcript.
1234
+ if (this.ctx.pendingMessagesContainer.hasLiveChild(bashComponent)) {
1235
+ this.ctx.pendingMessagesContainer.detachChild(bashComponent);
1236
+ addChatChild(this.ctx, bashComponent);
1237
+ }
1238
+ syncPendingExecutionComponents(this.ctx);
1187
1239
  }
1188
1240
 
1189
1241
  this.ctx.bashComponent = undefined;
@@ -1192,7 +1244,8 @@ export class CommandController {
1192
1244
 
1193
1245
  async handlePythonCommand(code: string, excludeFromContext = false): Promise<void> {
1194
1246
  const isDeferred = this.ctx.session.isStreaming;
1195
- this.ctx.pythonComponent = new EvalExecutionComponent(code, this.ctx.ui, excludeFromContext);
1247
+ const component = new EvalExecutionComponent(code, this.ctx.ui, excludeFromContext);
1248
+ this.ctx.pythonComponent = component;
1196
1249
 
1197
1250
  if (isDeferred) {
1198
1251
  this.ctx.pendingMessagesContainer.addChild(this.ctx.pythonComponent);
@@ -1210,7 +1263,9 @@ export class CommandController {
1210
1263
  this.ctx.pythonComponent.appendOutput(chunk);
1211
1264
  }
1212
1265
  },
1213
- { excludeFromContext },
1266
+ // A transcript rebuild racing this call must drop the parked block once the
1267
+ // session owns its message, otherwise the rebuilt row is rendered twice.
1268
+ { excludeFromContext, onPersisted: () => component.markResultPersisted() },
1214
1269
  );
1215
1270
 
1216
1271
  if (this.ctx.pythonComponent) {
@@ -281,6 +281,31 @@ export function addChatChild(ctx: InteractiveModeContext, component: Component):
281
281
  trimChatChildren(ctx);
282
282
  }
283
283
 
284
+ /**
285
+ * Parked `!`/`$` execution components are listed in `pendingBashComponents` /
286
+ * `pendingPythonComponents`, but `pendingMessagesContainer` is the only
287
+ * authority on parentage: `/clear`, `/context-clear`, extension redraws and the
288
+ * selector all call `pendingMessagesContainer.clear()`, which disposes and
289
+ * evicts parked components without touching those arrays. Drop every entry the
290
+ * container no longer holds so nothing downstream can move a dead component.
291
+ */
292
+ export function syncPendingExecutionComponents(ctx: InteractiveModeContext): void {
293
+ const container = ctx.pendingMessagesContainer;
294
+ ctx.pendingBashComponents = ctx.pendingBashComponents.filter(component => container.hasLiveChild(component));
295
+ ctx.pendingPythonComponents = ctx.pendingPythonComponents.filter(component => container.hasLiveChild(component));
296
+ }
297
+
298
+ /**
299
+ * Whether the session already owns this execution's message, i.e. the rebuilt
300
+ * transcript renders the block from session state. Set by the controller when
301
+ * `executeBash()` / `executePython()` reports the result as persisted.
302
+ */
303
+ function hasPersistedExecutionResult(component: Component): boolean {
304
+ if (component instanceof BashExecutionComponent) return component.hasPersistedResult();
305
+ if (component instanceof EvalExecutionComponent) return component.hasPersistedResult();
306
+ return false;
307
+ }
308
+
284
309
  export function trimChatChildren(ctx: InteractiveModeContext): void {
285
310
  const children = ctx.chatContainer.children;
286
311
 
@@ -899,10 +924,24 @@ export class UiHelpers {
899
924
  // This path is used to rebuild the visible chat transcript (e.g. after custom/debug UI).
900
925
  // Clear existing rendered chat first to avoid duplicating the full session in the container.
901
926
  const preservedChatChildren = options.preserveExistingChat ? this.ctx.chatContainer.children : undefined;
927
+ // A still-running deferred `!`/`$` block is the only rendering of output whose
928
+ // message has not been published to the session yet, so the rebuild must keep it
929
+ // parked instead of disposing it. Finished blocks are dropped here: the rebuilt
930
+ // transcript renders them from the session. A block whose result was persisted
931
+ // while its controller was still suspended is finished for this purpose — keeping
932
+ // it would render the same execution twice.
933
+ const runningExecutionComponents = this.#detachPendingMessages(
934
+ component =>
935
+ (component === this.ctx.bashComponent || component === this.ctx.pythonComponent) &&
936
+ !hasPersistedExecutionResult(component),
937
+ );
938
+ this.ctx.pendingBashComponents = this.ctx.pendingBashComponents.filter(component =>
939
+ runningExecutionComponents.includes(component),
940
+ );
941
+ this.ctx.pendingPythonComponents = this.ctx.pendingPythonComponents.filter(component =>
942
+ runningExecutionComponents.includes(component),
943
+ );
902
944
  this.ctx.chatContainer.clear();
903
- this.ctx.pendingMessagesContainer.clear();
904
- this.ctx.pendingBashComponents = [];
905
- this.ctx.pendingPythonComponents = [];
906
945
 
907
946
  // Reuse a pre-built context when available (e.g. from navigateTree) to avoid a second O(N) walk.
908
947
  const context = prebuiltContext ?? this.ctx.sessionManager.buildSessionContext();
@@ -923,6 +962,9 @@ export class UiHelpers {
923
962
  const times = compactionCount === 1 ? "1 time" : `${compactionCount} times`;
924
963
  this.ctx.showStatus(`Session compacted ${times}`);
925
964
  }
965
+ for (const component of runningExecutionComponents) {
966
+ this.ctx.pendingMessagesContainer.addChild(component);
967
+ }
926
968
  if (preservedChatChildren && preservedChatChildren.length > 0) {
927
969
  for (const child of preservedChatChildren) {
928
970
  addChatChild(this.ctx, child);
@@ -978,8 +1020,35 @@ export class UiHelpers {
978
1020
  this.ctx.ui.requestRender();
979
1021
  }
980
1022
 
1023
+ /**
1024
+ * Empty the pending container, disposing the queued-message chips but handing back
1025
+ * the parked `!`/`$` execution components matched by `retain`, in render order.
1026
+ *
1027
+ * `Container.clear()` disposes every child, which would tear down a running
1028
+ * execution block mid-flight; retained components are reused instances that the
1029
+ * caller re-attaches (pending area or chat transcript).
1030
+ */
1031
+ #detachPendingMessages(retain: (component: Component) => boolean): Component[] {
1032
+ syncPendingExecutionComponents(this.ctx);
1033
+ const parked = new Set<Component>([...this.ctx.pendingBashComponents, ...this.ctx.pendingPythonComponents]);
1034
+ const retained: Component[] = [];
1035
+ for (const child of this.ctx.pendingMessagesContainer.children) {
1036
+ if (parked.has(child) && retain(child)) {
1037
+ retained.push(child);
1038
+ } else {
1039
+ child.dispose?.();
1040
+ }
1041
+ }
1042
+ this.ctx.pendingMessagesContainer.detachAll();
1043
+ return retained;
1044
+ }
1045
+
981
1046
  updatePendingMessagesDisplay(): void {
982
- this.ctx.pendingMessagesContainer.clear();
1047
+ // Rebuild only the queued-message chips: parked execution components stay attached
1048
+ // so a mid-turn queue/dequeue event cannot dispose a streaming `!`/`$` block.
1049
+ for (const component of this.#detachPendingMessages(() => true)) {
1050
+ this.ctx.pendingMessagesContainer.addChild(component);
1051
+ }
983
1052
  const queuedMessages = this.ctx.session.getQueuedMessages() as QueuedMessages;
984
1053
 
985
1054
  const steeringMessages: Array<{ message: string; label: string }> = [];
@@ -1237,20 +1306,21 @@ export class UiHelpers {
1237
1306
  }
1238
1307
  }
1239
1308
 
1240
- /** Move pending bash components from pending area to chat */
1309
+ /** Move pending bash/python components from the pending area to chat */
1241
1310
  flushPendingBashComponents(): void {
1242
1311
  // Move (detach, not dispose) the live execution components from the pending
1243
1312
  // area into the chat transcript — they are reused instances, so a disposing
1244
- // removeChild() would tear them down before re-adding.
1245
- for (const component of this.ctx.pendingBashComponents) {
1313
+ // removeChild() would tear them down before re-adding. Walk the container so
1314
+ // the transcript keeps the order the pending area rendered, and so a
1315
+ // component the container no longer holds can never be re-parented.
1316
+ syncPendingExecutionComponents(this.ctx);
1317
+ const parked = new Set<Component>([...this.ctx.pendingBashComponents, ...this.ctx.pendingPythonComponents]);
1318
+ for (const component of [...this.ctx.pendingMessagesContainer.children]) {
1319
+ if (!parked.has(component)) continue;
1246
1320
  this.ctx.pendingMessagesContainer.detachChild(component);
1247
1321
  addChatChild(this.ctx, component);
1248
1322
  }
1249
1323
  this.ctx.pendingBashComponents = [];
1250
- for (const component of this.ctx.pendingPythonComponents) {
1251
- this.ctx.pendingMessagesContainer.detachChild(component);
1252
- addChatChild(this.ctx, component);
1253
- }
1254
1324
  this.ctx.pendingPythonComponents = [];
1255
1325
  }
1256
1326
 
@@ -1,10 +1,11 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { HEARTBEAT_TTL_MS } from "../bus/daemon-paths";
3
- import { SdkClient, SdkClientError, type SdkClientOptions, type SdkFrame } from "../client";
2
+ import { lifecycleRequestTimeoutMs } from "../broker/startup-budget";
3
+ import { SdkClient, SdkClientError, type SdkFrame } from "../client";
4
4
  import { assertReverseResponseFrame, ReverseLeaseError } from "../host/reverse-leases";
5
5
  import { validateAdapterControl, validateAdapterSecretFields } from "../protocol/adapter-validation";
6
-
7
6
  import { OPERATIONS } from "../protocol/operation-registry";
7
+ import { ACP_SESSION_RECONNECT } from "../session-reconnect";
8
+ import type { SessionLifecycleMcpServer } from "./mcp";
8
9
 
9
10
  type JsonObject = Record<string, unknown>;
10
11
 
@@ -40,6 +41,40 @@ export class AcpSdkAdapterError extends Error {
40
41
  }
41
42
  }
42
43
 
44
+ /**
45
+ * Lifecycle failures the ACP MCP launch wrapper must report verbatim. Everything else
46
+ * is re-attributed to the configured MCP servers, which is the useful answer for a
47
+ * launch that actually reached them — but a startup the broker ended before admission
48
+ * never opened an MCP handshake, so blaming the servers would hide both the real
49
+ * authority reason and the fact that the request is safely retryable.
50
+ */
51
+ const ACP_MCP_PRESERVED_LAUNCH_CODES = new Set([
52
+ "invalid_input",
53
+ "authentication_failed",
54
+ "unknown_model_profile",
55
+ "model_profile_registry_error",
56
+ "startup_admission_refused",
57
+ "startup_admission_timeout",
58
+ ]);
59
+
60
+ /**
61
+ * The error an ACP session launch must throw once a lifecycle request that carried MCP
62
+ * servers has failed.
63
+ */
64
+ export function acpMcpLaunchFailure(error: unknown, mcpServers: SessionLifecycleMcpServer[]): unknown {
65
+ const code =
66
+ typeof error === "object" && error !== null && "code" in error && typeof error.code === "string"
67
+ ? error.code
68
+ : undefined;
69
+ if (mcpServers.length === 0 || (code !== undefined && ACP_MCP_PRESERVED_LAUNCH_CODES.has(code))) return error;
70
+ const names = mcpServers
71
+ .slice(0, 8)
72
+ .map(server => server.name)
73
+ .join(", ");
74
+ const suffix = mcpServers.length > 8 ? `, and ${mcpServers.length - 8} more` : "";
75
+ return new AcpSdkAdapterError("unavailable", `MCP server request failed to start (${names}${suffix}).`);
76
+ }
77
+
43
78
  export type AcpReconnectFailedHandler = (error: SdkClientError) => void;
44
79
  export type AcpFrameHandler = (frame: SdkFrame) => void;
45
80
  type ReverseRequest = {
@@ -48,43 +83,7 @@ type ReverseRequest = {
48
83
  cancelTimer?: NodeJS.Timeout;
49
84
  };
50
85
 
51
- type AcpSessionReconnectOptions = Required<
52
- Pick<SdkClientOptions, "reconnectAttempts" | "reconnectBackoffMs" | "reconnectMaxBackoffMs">
53
- >;
54
-
55
- const ACP_RECONNECT_BACKOFF_MS = 250;
56
- const ACP_RECONNECT_MAX_BACKOFF_MS = 2_000;
57
- /** Cover twice the host heartbeat TTL so a reaped stall still has room to recover. */
58
- const ACP_RECONNECT_BUDGET_MS = 2 * HEARTBEAT_TTL_MS;
59
-
60
- function attemptsCovering(budgetMs: number): number {
61
- let elapsed = 0;
62
- let attempts = 0;
63
- while (elapsed <= budgetMs) {
64
- elapsed += Math.min(ACP_RECONNECT_BACKOFF_MS * 2 ** attempts, ACP_RECONNECT_MAX_BACKOFF_MS);
65
- attempts++;
66
- }
67
- return attempts;
68
- }
69
-
70
- /**
71
- * Reconnect budget for long-lived ACP sessions.
72
- *
73
- * Invariant: the client's total reconnect budget must outlive the host heartbeat
74
- * TTL ({@link HEARTBEAT_TTL_MS}). The SDK host drops a session whose client has
75
- * not ponged within that TTL, so a client that gives up reconnecting sooner turns
76
- * every stall the host reaps into a permanently lost session ("SDK WebSocket
77
- * reconnect attempts exhausted"). The transport defaults (3 attempts at 25ms base
78
- * = 175ms total) are correct only for one-shot request clients.
79
- *
80
- * Backoff ramps 250 -> 500 -> 1000 and then holds at the 2s cap, so individual
81
- * sleeps stay short and recovery is prompt once the host answers again.
82
- */
83
- export const ACP_SESSION_RECONNECT: AcpSessionReconnectOptions = {
84
- reconnectAttempts: attemptsCovering(ACP_RECONNECT_BUDGET_MS),
85
- reconnectBackoffMs: ACP_RECONNECT_BACKOFF_MS,
86
- reconnectMaxBackoffMs: ACP_RECONNECT_MAX_BACKOFF_MS,
87
- };
86
+ export { ACP_SESSION_RECONNECT };
88
87
 
89
88
  const SESSION_GLOBALS: Record<string, string> = {
90
89
  newSession: "session.create",
@@ -224,13 +223,15 @@ export class AcpSdkAdapter {
224
223
  this.#assertGenericDisposition("global", operation);
225
224
  if (isLifecycleOperation(operation) && !idempotencyKey)
226
225
  throw new AcpSdkAdapterError("invalid_input", "idempotencyKey is required for lifecycle operations.");
227
- const readinessTimeoutMs =
228
- typeof input.readinessTimeoutMs === "number" && Number.isSafeInteger(input.readinessTimeoutMs)
229
- ? input.readinessTimeoutMs
230
- : undefined;
226
+ // The broker may hold a startup in its admission queue before the readiness
227
+ // clock even starts, so the caller deadline covers the queue wait too; sizing
228
+ // it on readiness alone times out requests the broker is still running. A
229
+ // request that named no readiness budget is queued for the default one, so it
230
+ // needs the same extension rather than the client's generic request deadline.
231
+ const timeoutMs = lifecycleRequestTimeoutMs(operation, input);
231
232
  return await this.#client.global(operation, input, {
232
233
  idempotencyKey,
233
- ...(readinessTimeoutMs ? { timeoutMs: readinessTimeoutMs + 1_000 } : {}),
234
+ ...(timeoutMs === undefined ? {} : { timeoutMs }),
234
235
  });
235
236
  }
236
237
 
@@ -1,2 +1,2 @@
1
1
  export type { AcpProviderRegistration, AcpReverseConnection, AcpSdkAdapterOptions } from "./adapter";
2
- export { ACP_SESSION_RECONNECT, AcpSdkAdapter, AcpSdkAdapterError } from "./adapter";
2
+ export { ACP_SESSION_RECONNECT, AcpSdkAdapter, AcpSdkAdapterError, acpMcpLaunchFailure } from "./adapter";