@gajae-code/coding-agent 0.18.0 → 0.18.5

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 (86) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/dist/types/cli/stats-cli.d.ts +0 -5
  3. package/dist/types/config/autorouting-tier-map.d.ts +29 -2
  4. package/dist/types/config/model-registry.d.ts +16 -1
  5. package/dist/types/config/settings-schema.d.ts +2 -2
  6. package/dist/types/exa/mcp-client.d.ts +1 -5
  7. package/dist/types/exec/bash-shell-guardian.d.ts +26 -0
  8. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +1 -0
  9. package/dist/types/hashline/parser.d.ts +28 -5
  10. package/dist/types/modes/acp/acp-agent.d.ts +6 -1
  11. package/dist/types/sdk/acp/adapter.d.ts +3 -1
  12. package/dist/types/sdk/broker/lifecycle.d.ts +6 -1
  13. package/dist/types/sdk/broker/recovery-backoff.d.ts +62 -0
  14. package/dist/types/sdk/broker/runtime.d.ts +20 -0
  15. package/dist/types/sdk/broker/startup-budget.d.ts +5 -1
  16. package/dist/types/sdk/bus/index.d.ts +1 -0
  17. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  18. package/dist/types/sdk/client/client.d.ts +5 -1
  19. package/dist/types/sdk/host/session-runtime.d.ts +7 -0
  20. package/dist/types/sdk/prompt-watchdog.d.ts +1 -1
  21. package/dist/types/session/agent-session.d.ts +2 -0
  22. package/dist/types/task/index.d.ts +0 -1
  23. package/dist/types/tools/bash.d.ts +1 -0
  24. package/dist/types/tools/descriptors.d.ts +5 -0
  25. package/dist/types/tools/eval-backends.d.ts +37 -0
  26. package/dist/types/tools/eval.d.ts +1 -6
  27. package/dist/types/tools/index.d.ts +1 -36
  28. package/dist/types/tools/session-descriptions.d.ts +22 -0
  29. package/package.json +7 -7
  30. package/scripts/generate-sdk-operation-inventory.ts +2 -0
  31. package/src/cli/doctor/managed-link.ts +4 -4
  32. package/src/cli/doctor/repairs.ts +9 -1
  33. package/src/cli/stats-cli.ts +4 -36
  34. package/src/cli-main.ts +4 -8
  35. package/src/cli.ts +4 -8
  36. package/src/commands/commit.ts +2 -2
  37. package/src/commands/sdk.ts +1 -2
  38. package/src/config/autorouting-tier-map.ts +20 -2
  39. package/src/config/model-preset-registry.ts +17 -2
  40. package/src/config/model-profile-activation.ts +3 -1
  41. package/src/config/model-profiles.ts +11 -3
  42. package/src/config/model-registry.ts +45 -17
  43. package/src/config/settings-schema.ts +8 -2
  44. package/src/exa/mcp-client.ts +41 -8
  45. package/src/exec/bash-shell-guardian.ts +214 -48
  46. package/src/gjc-runtime/session-state-sidecar.ts +88 -58
  47. package/src/hashline/execute.ts +51 -3
  48. package/src/hashline/parser.ts +63 -16
  49. package/src/internal-urls/docs-index.generated.ts +25 -25
  50. package/src/modes/acp/acp-agent.ts +136 -20
  51. package/src/modes/controllers/ssh-command-controller.ts +3 -1
  52. package/src/modes/interactive-mode.ts +10 -0
  53. package/src/modes/utils/context-usage.ts +2 -6
  54. package/src/prompts/tools/task.md +8 -27
  55. package/src/sdk/acp/adapter.ts +38 -3
  56. package/src/sdk/broker/doctor-restart.ts +38 -3
  57. package/src/sdk/broker/lifecycle.ts +408 -218
  58. package/src/sdk/broker/recovery-backoff.ts +135 -0
  59. package/src/sdk/broker/runtime.ts +83 -0
  60. package/src/sdk/broker/startup-budget.ts +6 -1
  61. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  62. package/src/sdk/bus/index.ts +308 -22
  63. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  64. package/src/sdk/client/client.ts +53 -35
  65. package/src/sdk/host/session-runtime.ts +99 -3
  66. package/src/sdk/prompt-watchdog.ts +17 -4
  67. package/src/sdk/protocol/operation-inventory.generated.json +11 -0
  68. package/src/session/agent-session.ts +79 -17
  69. package/src/session/internal/managed-session-storage.ts +23 -0
  70. package/src/task/index.ts +21 -66
  71. package/src/tools/bash.ts +39 -0
  72. package/src/tools/descriptors.ts +27 -1
  73. package/src/tools/eval-backends.ts +95 -0
  74. package/src/tools/eval.ts +3 -15
  75. package/src/tools/index.ts +9 -88
  76. package/src/tools/search.ts +2 -7
  77. package/src/tools/session-descriptions.ts +135 -0
  78. package/src/tools/tool-catalog.generated.ts +1 -1
  79. package/dist/types/cli/grep-cli.d.ts +0 -17
  80. package/dist/types/cli/shell-cli.d.ts +0 -8
  81. package/dist/types/commands/grep.d.ts +0 -42
  82. package/dist/types/commands/shell.d.ts +0 -21
  83. package/src/cli/grep-cli.ts +0 -160
  84. package/src/cli/shell-cli.ts +0 -176
  85. package/src/commands/grep.ts +0 -48
  86. package/src/commands/shell.ts +0 -29
@@ -214,6 +214,12 @@ interface PromptWaiter {
214
214
  /** Whether ANY overlapping cancel attempt for this prompt was acknowledged:
215
215
  * a later failed attempt must not erase an earlier success (review thread P2). */
216
216
  cancelAcknowledged?: boolean;
217
+ /** Durable cancel intent after a no-effect abort before this waiter starts work; retained until settlement. */
218
+ cancelBeforeAdmission?: boolean;
219
+ /** Abort scope replayed after admission and again at correlated start if no turn was active yet. */
220
+ cancelBeforeAdmissionScope?: AbortScope;
221
+ /** A correlated start may need one final abort after an admitted-but-not-running no-effect response. */
222
+ cancelAfterStartAbortIssued?: boolean;
217
223
  /** In-flight cancel attempts for this prompt: cancellation intent must stay
218
224
  * set while any attempt can still acknowledge, so a failure only clears
219
225
  * the shared flag when no attempt remains pending (review thread P2). */
@@ -591,6 +597,12 @@ function isAbortAcknowledged(value: unknown, scope: AbortScope): boolean {
591
597
  if (result.aborted === true) return true;
592
598
  return result.ok === true && result.turn === "stopped" && result.selection === scope;
593
599
  }
600
+ function isAbortNoEffect(value: unknown): boolean {
601
+ const candidate = object(value);
602
+ const result = object(candidate?.result) ?? candidate;
603
+ return result?.turn === "no_active_turn" || result?.turn === "no_effect" || result?.turn === "no_store";
604
+ }
605
+
594
606
  function strictCorrelationFrom(...values: unknown[]): PromptCorrelation | undefined {
595
607
  const correlation: PromptCorrelation = {};
596
608
  let malformed = false;
@@ -655,7 +667,9 @@ function logDroppedPromptTerminal(
655
667
  });
656
668
  }
657
669
 
658
- type SdkPromptFailedOutcome = Extract<SdkPromptTerminalOutcome, { kind: "failed" }>;
670
+ type SdkPromptFailedOutcome = Extract<SdkPromptTerminalOutcome, { kind: "failed" }> & {
671
+ deliveryFailure?: { cause: string; frameBytes: number };
672
+ };
659
673
 
660
674
  /**
661
675
  * A prompt rejection that still carries the terminal's structured failure classification.
@@ -791,6 +805,13 @@ function promptFailureWireData(failure: SdkPromptFailedOutcome): Record<string,
791
805
  category: failure.category,
792
806
  retryability: promptFailureRetryability(failure.category),
793
807
  ...(isSafePromptFailureCode(failure.providerCode) ? { providerCode: failure.providerCode } : {}),
808
+ ...(failure.deliveryFailure
809
+ ? {
810
+ reason: "delivery_failed",
811
+ cause: failure.deliveryFailure.cause,
812
+ frameBytes: String(failure.deliveryFailure.frameBytes),
813
+ }
814
+ : {}),
794
815
  ...(operatorMessage === undefined ? {} : { operatorMessage }),
795
816
  };
796
817
  }
@@ -909,13 +930,28 @@ function terminalOutcome(event: JsonObject): SdkPromptTerminalOutcome | undefine
909
930
  typeof outcome.message === "string" &&
910
931
  (outcome.provenance === "agent_failed" || outcome.provenance === "deadline")
911
932
  ) {
912
- return failedPromptOutcome({
933
+ const failure = failedPromptOutcome({
913
934
  code: outcome.code,
914
935
  provenance: outcome.provenance,
915
936
  ...(typeof outcome.providerCode === "string" ? { providerCode: outcome.providerCode } : {}),
916
937
  ...(isSdkPromptFailurePhase(outcome.phase) ? { phase: outcome.phase } : {}),
917
938
  evidence: {},
918
939
  });
940
+ const delivery = object(event.error);
941
+ if (
942
+ delivery?.code === "delivery_failed" &&
943
+ isSafePromptFailureCode(delivery.cause) &&
944
+ typeof delivery.frameBytes === "number" &&
945
+ Number.isSafeInteger(delivery.frameBytes) &&
946
+ delivery.frameBytes >= 0
947
+ ) {
948
+ const classified: SdkPromptFailedOutcome = {
949
+ ...failure,
950
+ deliveryFailure: { cause: delivery.cause, frameBytes: delivery.frameBytes },
951
+ };
952
+ return classified;
953
+ }
954
+ return failure;
919
955
  }
920
956
  return undefined;
921
957
  }
@@ -1103,6 +1139,15 @@ function frameMessageRole(event: JsonObject | undefined): string | undefined {
1103
1139
  return typeof role === "string" ? role : undefined;
1104
1140
  }
1105
1141
 
1142
+ function frameMessageHasToolCall(event: JsonObject | undefined): boolean {
1143
+ const content = object(event?.message)?.content;
1144
+ if (!Array.isArray(content)) return false;
1145
+ return content.some(block => {
1146
+ const value = object(block);
1147
+ return value?.type === "toolCall" || value?.type === "tool_use";
1148
+ });
1149
+ }
1150
+
1106
1151
  const ACP_CONFIG_OPTIONS = [
1107
1152
  { id: MODEL_CONFIG_ID, name: "Model", category: "model", options: [] },
1108
1153
  { id: THINKING_CONFIG_ID, name: "Thinking", category: "thought_level", options: [] },
@@ -1495,6 +1540,11 @@ export function acpRequestFailure(error: unknown): unknown {
1495
1540
  const code = typeof error === "object" && error !== null && "code" in error ? error.code : undefined;
1496
1541
  if (typeof code !== "string") return error;
1497
1542
  const message = error instanceof Error ? error.message : code;
1543
+ // ACP clients render `data.details` after the JSON-RPC message. The MCP launch
1544
+ // diagnostic is already the complete additional message, so repeating it in
1545
+ // `details` produces `Internal error: X.: X.` on the wire.
1546
+ const duplicateMcpLaunchDetails =
1547
+ code === "unavailable" && message.startsWith("MCP server request failed to start (");
1498
1548
  // A prompt terminal additionally publishes its classification (issue #5615). The
1499
1549
  // spread is empty for every other error, so their payloads are byte-identical to
1500
1550
  // before. `code`/`details` and the JSON-RPC code itself are untouched either way:
@@ -1502,8 +1552,8 @@ export function acpRequestFailure(error: unknown): unknown {
1502
1552
  const promptFailure = promptFailureFromDirectError(error);
1503
1553
  const data =
1504
1554
  promptFailure !== undefined
1505
- ? { code, details: message, ...promptFailureWireData(promptFailure) }
1506
- : { code, details: message };
1555
+ ? { code, ...(duplicateMcpLaunchDetails ? {} : { details: message }), ...promptFailureWireData(promptFailure) }
1556
+ : { code, ...(duplicateMcpLaunchDetails ? {} : { details: message }) };
1507
1557
  // An abandoned prompt additionally publishes the plan it never finished (issue #5669).
1508
1558
  // Same rule as above: `code`/`details` and the JSON-RPC code are untouched, and an abandon
1509
1559
  // that observed no plan adds no keys, so its payload stays byte-identical to before.
@@ -2362,7 +2412,7 @@ export class AcpAgent implements Agent {
2362
2412
  this.#releaseRetiredPromptAcknowledgement(params.sessionId, waiter);
2363
2413
  return await response;
2364
2414
  }
2365
- if (record.cancelRequested && waiter.cancelAcknowledged) {
2415
+ if (record.cancelRequested && (waiter.cancelAcknowledged || waiter.cancelBeforeAdmission)) {
2366
2416
  this.#releaseRetiredPromptAcknowledgement(params.sessionId, waiter);
2367
2417
  await this.#settleCancelledPrompt(params.sessionId, record, waiter);
2368
2418
  return await response;
@@ -2385,7 +2435,7 @@ export class AcpAgent implements Agent {
2385
2435
  this.#releaseRetiredPromptAcknowledgement(params.sessionId, waiter);
2386
2436
  return await response;
2387
2437
  }
2388
- if (record.cancelRequested && waiter.cancelAcknowledged) {
2438
+ if (record.cancelRequested && (waiter.cancelAcknowledged || waiter.cancelBeforeAdmission)) {
2389
2439
  this.#releaseRetiredPromptAcknowledgement(params.sessionId, waiter);
2390
2440
  await this.#settleCancelledPrompt(params.sessionId, record, waiter);
2391
2441
  return await response;
@@ -2415,18 +2465,18 @@ export class AcpAgent implements Agent {
2415
2465
  record.adapter,
2416
2466
  );
2417
2467
  }
2418
- waiter.dispatched = true;
2419
- // The turn is now dispatched to the host: this prompt owned the session's first turn,
2420
- // so it — not a preflight rejection that threw before this point — is what settles
2421
- // `firstPromptDone` in `prompt()` (review P2).
2422
- if (retryReservation) retryReservation.admitted = true;
2423
2468
  if (waiter.settled || record.activePrompt !== waiter) {
2424
2469
  return await response;
2425
2470
  }
2426
- if (record.cancelRequested && waiter.cancelAcknowledged) {
2471
+ if (record.cancelRequested && (waiter.cancelAcknowledged || waiter.cancelBeforeAdmission)) {
2427
2472
  await this.#settleCancelledPrompt(params.sessionId, record, waiter);
2428
2473
  return await response;
2429
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;
2430
2480
 
2431
2481
  waiter.acknowledgementPending = true;
2432
2482
  const promptAdapter = record.adapter;
@@ -2546,6 +2596,19 @@ export class AcpAgent implements Agent {
2546
2596
  this.#rememberSettledPromptCorrelation(params.sessionId, record, acknowledgementCorrelation);
2547
2597
  return await response;
2548
2598
  }
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
+ );
2611
+ }
2549
2612
  // Frames held while ownership was unknown belong to this prompt only when the
2550
2613
  // acknowledgement proves their complete correlation matches exactly.
2551
2614
  const deferredActivityFrames = waiter.deferredActivityFrames.splice(0);
@@ -2691,7 +2754,10 @@ export class AcpAgent implements Agent {
2691
2754
  // return the semantically meaningful `cancelled` stop reason, so that Clients
2692
2755
  // can reliably confirm the cancellation." Surfacing the transport's `busy`
2693
2756
  // rejection instead would show the user a spurious error for their own cancel.
2694
- if (record.cancelRequested && waiter.cancelAcknowledged) {
2757
+ if (
2758
+ record.cancelRequested &&
2759
+ (waiter.cancelAcknowledged || (waiter.cancelBeforeAdmission && !waiter.acknowledged && !waiter.dispatched))
2760
+ ) {
2695
2761
  record.cancelRequested = false;
2696
2762
  // The client's turn is settled by the return; the advisory idle publication
2697
2763
  // must not gate it and must still be attempted so the running phase is
@@ -2715,6 +2781,8 @@ export class AcpAgent implements Agent {
2715
2781
  // `_meta.gjc.abortScope: "owned"` (or `GJC_ACP_ABORT_SCOPE=owned`).
2716
2782
  const scope = resolveAcpAbortScope(params._meta, process.env);
2717
2783
  const waiter = record.activePrompt;
2784
+ const waiterWasUnacknowledged = waiter !== undefined && !waiter.acknowledged;
2785
+ const waiterWasBeforeActivity = waiter !== undefined && !waiter.observedTurnActivity;
2718
2786
  if (waiter) {
2719
2787
  // Overlapping cancels must not lose an earlier successful
2720
2788
  // acknowledgement: ANY successful attempt resolves the shared
@@ -2733,9 +2801,27 @@ export class AcpAgent implements Agent {
2733
2801
  }
2734
2802
  }
2735
2803
  try {
2736
- const acknowledgement = await record.adapter.cancel(scope);
2804
+ let acknowledgement = await record.adapter.cancel(scope);
2805
+ let acknowledgementScope = scope;
2806
+ if (
2807
+ !isAbortAcknowledged(acknowledgement, scope) &&
2808
+ isAbortNoEffect(acknowledgement) &&
2809
+ (waiterWasUnacknowledged || waiterWasBeforeActivity) &&
2810
+ waiter &&
2811
+ record.activePrompt === waiter &&
2812
+ !waiter.settled
2813
+ ) {
2814
+ waiter.cancelBeforeAdmission = true;
2815
+ waiter.cancelBeforeAdmissionScope =
2816
+ waiter.cancelBeforeAdmissionScope === "owned" || scope === "owned" ? "owned" : "turn";
2817
+ if (waiter.acknowledged) {
2818
+ // The first abort raced prompt admission. Retry now that the turn is admitted.
2819
+ acknowledgementScope = waiter.cancelBeforeAdmissionScope;
2820
+ acknowledgement = await record.adapter.cancel(acknowledgementScope);
2821
+ }
2822
+ }
2737
2823
  const result = object(object(acknowledgement)?.result) ?? object(acknowledgement);
2738
- if (!isAbortAcknowledged(acknowledgement, scope))
2824
+ if (!isAbortAcknowledged(acknowledgement, acknowledgementScope))
2739
2825
  throw new AcpSdkAdapterError(
2740
2826
  "abort_unacknowledged",
2741
2827
  "SDK did not acknowledge cancellation of the active prompt.",
@@ -2846,7 +2932,12 @@ export class AcpAgent implements Agent {
2846
2932
  } finally {
2847
2933
  if (waiter) {
2848
2934
  waiter.pendingCancelAttempts = Math.max(0, (waiter.pendingCancelAttempts ?? 1) - 1);
2849
- if (waiter.pendingCancelAttempts === 0 && !waiter.cancelAcknowledged && record.activePrompt === waiter) {
2935
+ if (
2936
+ waiter.pendingCancelAttempts === 0 &&
2937
+ !waiter.cancelAcknowledged &&
2938
+ !waiter.cancelBeforeAdmission &&
2939
+ record.activePrompt === waiter
2940
+ ) {
2850
2941
  record.cancelRequested = false;
2851
2942
  }
2852
2943
  }
@@ -2869,8 +2960,9 @@ export class AcpAgent implements Agent {
2869
2960
 
2870
2961
  async #settleCancelledPrompt(id: string, record: SessionRecord, waiter: PromptWaiter): Promise<void> {
2871
2962
  // The authoritative terminal wins whenever it arrives in time; this only runs
2872
- // when nothing settled the prompt after the SDK acknowledged the cancellation.
2873
- if (!waiter.cancelAcknowledged) return;
2963
+ // after an acknowledged abort or before turn dispatch for this waiter.
2964
+ const preAdmissionCancellation = waiter.cancelBeforeAdmission && !waiter.acknowledged && !waiter.dispatched;
2965
+ if (!waiter.cancelAcknowledged && !preAdmissionCancellation) return;
2874
2966
  if (this.#sessions.get(id) !== record || record.activePrompt !== waiter || waiter.settled) return;
2875
2967
  if (waiter.terminalReserved) return;
2876
2968
  // An overlapping acknowledged cancel does not supersede the earlier
@@ -2893,6 +2985,8 @@ export class AcpAgent implements Agent {
2893
2985
  if (!record.backgroundBusy) record.busy = false;
2894
2986
  clearPromptWatchdog(waiter);
2895
2987
  record.cancelRequested = false;
2988
+ waiter.cancelBeforeAdmission = false;
2989
+ waiter.cancelBeforeAdmissionScope = undefined;
2896
2990
  waiter.settled = true;
2897
2991
  this.#fenceRetiredPromptAcknowledgement(id, waiter);
2898
2992
  waiter.deferredFrames.length = 0;
@@ -4144,6 +4238,7 @@ export class AcpAgent implements Agent {
4144
4238
  typeof event?.type === "string" ? event.type : undefined,
4145
4239
  typeof event?.toolCallId === "string" ? event.toolCallId : undefined,
4146
4240
  frameMessageRole(event),
4241
+ frameMessageHasToolCall(event),
4147
4242
  );
4148
4243
  }
4149
4244
 
@@ -4605,6 +4700,24 @@ export class AcpAgent implements Agent {
4605
4700
  }
4606
4701
  if (toolCallId && event.type === "tool_execution_end") record.toolArgs.delete(toolCallId);
4607
4702
  if (event.type === "agent_start") {
4703
+ if (
4704
+ promptOwner?.cancelBeforeAdmission &&
4705
+ record.cancelRequested &&
4706
+ !promptOwner.cancelAcknowledged &&
4707
+ !promptOwner.cancelAfterStartAbortIssued
4708
+ ) {
4709
+ promptOwner.cancelAfterStartAbortIssued = true;
4710
+ const scope = promptOwner.cancelBeforeAdmissionScope ?? "turn";
4711
+ void this.cancel({
4712
+ sessionId: id,
4713
+ _meta: { gjc: { abortScope: scope } },
4714
+ } as CancelNotification).catch(error =>
4715
+ logger.warn("ACP could not retry a cancel after prompt start", {
4716
+ sessionId: id,
4717
+ error: error instanceof Error ? error.message : String(error),
4718
+ }),
4719
+ );
4720
+ }
4608
4721
  await this.#publishSessionUpdate(
4609
4722
  id,
4610
4723
  {
@@ -4874,6 +4987,8 @@ export class AcpAgent implements Agent {
4874
4987
  record.activePrompt = undefined;
4875
4988
  clearPromptWatchdog(waiter);
4876
4989
  waiter.settled = true;
4990
+ waiter.cancelBeforeAdmission = false;
4991
+ waiter.cancelBeforeAdmissionScope = undefined;
4877
4992
  const { outcome } = waiter.terminal;
4878
4993
  this.#rememberSettledPromptCorrelation(id, record, waiter.correlation);
4879
4994
  if (outcome.kind === "stopped") {
@@ -4885,10 +5000,11 @@ export class AcpAgent implements Agent {
4885
5000
  // connection watched start. Upgrade it from the frames this prompt actually owned, never
4886
5001
  // downgrade a terminal that already reported `post_start`, and carry the whole
4887
5002
  // classification on the rejection so the first-turn retry gate can read it (review P1).
4888
- const failure =
5003
+ const failure = (
4889
5004
  outcome.phase === "post_start"
4890
5005
  ? outcome
4891
- : (rephaseFailedOutcome(outcome, { hasActivity: waiter.observedTurnActivity }) as SdkPromptFailedOutcome);
5006
+ : rephaseFailedOutcome(outcome, { hasActivity: waiter.observedTurnActivity })
5007
+ ) as SdkPromptFailedOutcome;
4892
5008
  waiter.reject(new AcpPromptFailureError(failure));
4893
5009
  }
4894
5010
 
@@ -134,7 +134,9 @@ export class SSHCommandController {
134
134
  this.ctx.showError("Missing value for --port.");
135
135
  return;
136
136
  }
137
- const parsed = Number.parseInt(value, 10);
137
+ // Same rule as the ACP `/ssh add` parser: `Number.parseInt` accepts
138
+ // trailing garbage ("22oops" -> 22), which would save a typo as a port.
139
+ const parsed = /^\d+$/.test(value) ? Number.parseInt(value, 10) : Number.NaN;
138
140
  if (Number.isNaN(parsed) || parsed < 1 || parsed > 65535) {
139
141
  this.ctx.showError("Invalid --port value. Must be an integer between 1 and 65535.");
140
142
  return;
@@ -565,6 +565,13 @@ export class InteractiveMode implements InteractiveModeContext {
565
565
  #suspendedActivityIndicator?: Loader;
566
566
  #stopped = false;
567
567
  #initPromise?: Promise<void>;
568
+ // Until startup's first paint renders the session transcript, chatContainer holds
569
+ // only pre-paint notices. A transcript rebuild before then (e.g. an extension
570
+ // session_start message with display:true) would wipe those notices and leave
571
+ // session components that the first paint preserves and renders again, so every
572
+ // session-derived block would show twice. Rebuilds are no-ops until the first
573
+ // paint, which renders the full session anyway.
574
+ #initialTranscriptPainted = false;
568
575
  #stopListeners = new Set<() => void>();
569
576
  #eventBus?: EventBus;
570
577
  #eventBusUnsubscribers: Array<() => void> = [];
@@ -1561,6 +1568,7 @@ export class InteractiveMode implements InteractiveModeContext {
1561
1568
  }
1562
1569
 
1563
1570
  rebuildChatFromMessages(policy: TranscriptRebuildPolicy): void {
1571
+ if (!this.#initialTranscriptPainted) return;
1564
1572
  prepareTranscriptRebuild(this.ui, policy);
1565
1573
  this.resetAssistantTextPresentation();
1566
1574
  this.chatContainer.clear();
@@ -2184,11 +2192,13 @@ export class InteractiveMode implements InteractiveModeContext {
2184
2192
  prebuiltContext?: SessionContext,
2185
2193
  options?: { preserveExistingChat?: boolean },
2186
2194
  ): void {
2195
+ if (!this.#initialTranscriptPainted) return;
2187
2196
  prepareTranscriptRebuild(this.ui, policy);
2188
2197
  this.#uiHelpers.renderInitialMessages(prebuiltContext, options);
2189
2198
  }
2190
2199
  renderInitialMessages(prebuiltContext?: SessionContext, options?: { preserveExistingChat?: boolean }): void {
2191
2200
  this.#uiHelpers.renderInitialMessages(prebuiltContext, options);
2201
+ this.#initialTranscriptPainted = true;
2192
2202
  }
2193
2203
 
2194
2204
  getUserMessageText(message: Message): string {
@@ -1,10 +1,6 @@
1
1
  import type { AgentMessage } from "@gajae-code/agent-core";
2
2
  import type { CompactionSettings } from "@gajae-code/agent-core/compaction";
3
- import {
4
- effectiveReserveTokens,
5
- estimateMessageTokensHeuristic,
6
- resolveThresholdTokens,
7
- } from "@gajae-code/agent-core/compaction";
3
+ import { effectiveReserveTokens, estimateMessageTokensHeuristic } from "@gajae-code/agent-core/compaction";
8
4
  import type { Model } from "@gajae-code/ai/core";
9
5
  import { formatNumber } from "@gajae-code/utils";
10
6
  import type { AgentSession } from "../../session/agent-session";
@@ -135,7 +131,7 @@ export function computeContextBreakdown(
135
131
  if (contextWindow > 0) {
136
132
  const compactionSettings = session.settings.getGroup("compaction") as CompactionSettings;
137
133
  if (compactionSettings.enabled && compactionSettings.strategy !== "off") {
138
- const threshold = resolveThresholdTokens(contextWindow, compactionSettings);
134
+ const threshold = session.getAutoCompactionThresholdTokens(tokensForFreeSpace);
139
135
  autoCompactBufferTokens = Math.max(0, contextWindow - threshold);
140
136
  } else {
141
137
  autoCompactBufferTokens = 0;
@@ -2,18 +2,12 @@ Launches subagents to parallelize workflows.
2
2
 
3
3
  - Results are delivered automatically when complete.
4
4
  - The tool result lists the assigned task ids (e.g. `0-AuthLoader`) — those are the live agent ids.
5
- {{#if ircEnabled}}
6
- - Coordinate with running tasks via `irc` using those ids. `subagent` cancel terminates a task and **cannot carry a message**.
7
- {{else}}
5
+ - Coordinate with running tasks via `irc` when available, using those ids. `subagent` cancel terminates a task and **cannot carry a message**.
8
6
  - Use `subagent` action `inspect` or `list` to snapshot manager state.
9
- {{/if}}
10
7
  - To wait or cancel, use the `subagent` tool; its await/cancel doctrine is authoritative.
11
8
 
12
- {{#if ircEnabled}}
13
- Subagents have no conversation history, but they can reach you and their siblings live via the `irc` tool. Front-load every fact, file path, and direction they need in {{#if contextEnabled}}`context` or `assignment`{{else}}each `assignment`{{/if}}.
14
- {{else}}
15
9
  Subagents have no conversation history. Every fact, file path, and direction they need MUST be explicit in {{#if contextEnabled}}`context` or `assignment`{{else}}each `assignment`{{/if}}.
16
- {{/if}}
10
+ Use `irc` for live coordination when it is available.
17
11
 
18
12
  <parameters>
19
13
  - `agent`: agent type for all tasks
@@ -36,27 +30,16 @@ Subagents have no conversation history. Every fact, file path, and direction the
36
30
  - HARD runtime gate: calls with more than 4 tasks are rejected before any child launches unless `spawnPlan` is complete.
37
31
  - NEVER assign tasks to run project-wide build/test/lint. Caller verifies after the batch.
38
32
  - **Subagents do not verify, lint, or format.** Every assignment MUST instruct the subagent to skip all gates and formatters. You run them once at the end across the union of changed files — avoids redundant runs and racing formatter passes.
39
- {{#if ircEnabled}}
40
- - Each task: ≤3–5 explicit files. Overlapping file sets are tolerable when peers can coordinate via `irc`, but still fan out to a cluster when the scopes are cleanly separable.
41
- - No globs, no "update all", no package-wide scope.
42
- {{else}}
43
- - Each task: ≤3–5 explicit files. No globs, no "update all", no package-wide scope. Fan out to a cluster instead.
44
- {{/if}}
33
+ - Each task: ≤3–5 explicit files. No globs, no "update all", no package-wide scope. Use `irc` to coordinate overlapping work when it is available; otherwise keep file sets independent.
45
34
  - Pass large payloads via `local://<path>` URIs, not inline.
46
35
  {{#if contextEnabled}}- Put shared constraints in `context` once; do not duplicate across assignments.{{/if}}
47
36
  - Prefer agents that investigate **and** edit in one pass; only spin a read-only discovery step when affected files are genuinely unknown.
48
37
  </rules>
49
38
 
50
39
  <parallelization>
51
- {{#if ircEnabled}}
52
- Test: can task B run correctly without seeing A's output? If no, sequence A → B — **unless** B can reasonably ask A for the missing piece over `irc`. Live coordination beats a serial waterfall when the contract is small and easy to describe in a DM.
53
- Still sequence when one task produces a large, evolving contract (generated types, schema migration, core module API) the other consumes wholesale — IRC round-trips do not replace a finished artifact.
54
- Parallel when tasks touch disjoint files, are independent refactors/tests, or only need occasional clarification that can be resolved peer-to-peer.
55
- {{else}}
56
- Test: can task B run correctly without seeing A's output? If no, sequence A → B.
57
- Sequential when one task produces a contract (types, API, schema, core module) the other consumes.
58
- Parallel when tasks touch disjoint files or are independent refactors/tests.
59
- {{/if}}
40
+ Test: can task B run correctly without seeing A's output? If no, sequence A → B, unless `irc` is available and A can answer a small clarification.
41
+ Still sequence when one task produces a large, evolving contract (generated types, schema migration, core module API); live coordination is not a substitute for a finished contract.
42
+ Parallel when tasks touch disjoint files, are independent refactors/tests, or need only occasional peer clarification.
60
43
  </parallelization>
61
44
 
62
45
  {{#if contextEnabled}}
@@ -77,10 +60,8 @@ Parallel when tasks touch disjoint files or are independent refactors/tests.
77
60
  {{#if spawningDisabled}}
78
61
  Agent spawning is disabled for this context.
79
62
  {{else}}
80
- {{#list agents join="\n"}}
81
- # {{name}}
82
- {{description}}
83
- {{/list}}
63
+ Bundled role names: {{#list agents join=", "}}{{name}}{{/list}}. A configured agent may override a bundled role name and takes precedence.
64
+ Other configured agents (project, user, plugin) may also be available; calling with an unknown `agent` lists the agents callable in this session.
84
65
  {{/if}}
85
66
  </agents>
86
67
 
@@ -112,6 +112,16 @@ function credentialFreeLifecycleResult(value: unknown): unknown {
112
112
  return output;
113
113
  }
114
114
 
115
+ const ACP_MCP_FAILURE_MESSAGE_MAX_LENGTH = 240;
116
+
117
+ function credentialFreeLifecycleMessage(error: unknown): string {
118
+ const message = error instanceof Error ? error.message : String(error);
119
+ return message
120
+ .replace(/\b(?:endpoint|token|url)\s*[:=]\s*(?:"[^"]*"|'[^']*'|\S+)/gi, "[redacted]")
121
+ .replace(/https?:\/\/[^\s)]+/gi, "[redacted-url]")
122
+ .slice(0, ACP_MCP_FAILURE_MESSAGE_MAX_LENGTH);
123
+ }
124
+
115
125
  /**
116
126
  * Lifecycle failures the ACP MCP launch wrapper must report verbatim. Everything else
117
127
  * is re-attributed to the configured MCP servers, which is the useful answer for a
@@ -135,22 +145,47 @@ const ACP_MCP_PRESERVED_LAUNCH_CODES = new Set([
135
145
  "worktree_in_use",
136
146
  ]);
137
147
 
148
+ /**
149
+ * SDK client transport failures happen before the lifecycle request reaches any MCP server and
150
+ * must retain their original retry semantics. The `transport` origin marker is required because
151
+ * broker ERROR frames are also represented as SdkClientError instances with these same codes.
152
+ */
153
+ const ACP_MCP_PRESERVED_TRANSPORT_CODES = new Set(["connection_closed", "unavailable", "timeout"]);
154
+
138
155
  /**
139
156
  * The error an ACP session launch must throw once a lifecycle request that carried MCP
140
- * servers has failed.
157
+ * servers has failed. Broker transport failures from the SDK client are preserved verbatim;
158
+ * other failures keep the adapter's `unavailable` code while carrying a bounded, credential-free
159
+ * copy of the original code and message as their cause diagnostic.
141
160
  */
142
161
  export function acpMcpLaunchFailure(error: unknown, mcpServers: SessionLifecycleMcpServer[]): unknown {
143
162
  const code =
144
163
  typeof error === "object" && error !== null && "code" in error && typeof error.code === "string"
145
164
  ? error.code
146
165
  : undefined;
147
- if (mcpServers.length === 0 || (code !== undefined && ACP_MCP_PRESERVED_LAUNCH_CODES.has(code))) return error;
166
+ if (
167
+ mcpServers.length === 0 ||
168
+ (code !== undefined && ACP_MCP_PRESERVED_LAUNCH_CODES.has(code)) ||
169
+ (error instanceof SdkClientError &&
170
+ error.transport === true &&
171
+ code !== undefined &&
172
+ ACP_MCP_PRESERVED_TRANSPORT_CODES.has(code))
173
+ )
174
+ return error;
148
175
  const names = mcpServers
149
176
  .slice(0, 8)
150
177
  .map(server => server.name)
151
178
  .join(", ");
152
179
  const suffix = mcpServers.length > 8 ? `, and ${mcpServers.length - 8} more` : "";
153
- return new AcpSdkAdapterError("unavailable", `MCP server request failed to start (${names}${suffix}).`);
180
+ const originalCode = code ?? "unknown";
181
+ const originalMessage = credentialFreeLifecycleMessage(error);
182
+ const diagnostic = originalMessage ? ` [${originalCode}: ${originalMessage}]` : ` [${originalCode}]`;
183
+ const wrapped = new AcpSdkAdapterError(
184
+ "unavailable",
185
+ `MCP server request failed to start (${names}${suffix}): broker lifecycle failed${diagnostic}`,
186
+ );
187
+ Object.assign(wrapped, { cause: error });
188
+ return wrapped;
154
189
  }
155
190
 
156
191
  export type AcpReconnectFailedHandler = (error: SdkClientError) => void;
@@ -63,6 +63,41 @@ function typedResult(response: unknown): BrokerRestartResult {
63
63
  return { ok: false, error: { code: "protocol_error", message: "broker returned a malformed restart response" } };
64
64
  }
65
65
 
66
+ /**
67
+ * Sends one restart verb. SdkClient rejects an `{ok:false}` response as an
68
+ * SdkClientError, but that refusal is the broker's own typed decision, so it is
69
+ * returned as the result. Only a request that got no response for its id (its
70
+ * outcome is unknown) propagates as an error.
71
+ */
72
+ async function restartRequest(
73
+ client: SdkClient,
74
+ operation: string,
75
+ input: Record<string, unknown>,
76
+ ): Promise<BrokerRestartResult> {
77
+ let requestFrameId: unknown;
78
+ let refused = false;
79
+ const unsubscribe = client.onFrame(frame => {
80
+ const id = typeof frame.id === "string" ? frame.id : frame.requestId;
81
+ if (requestFrameId !== undefined && id === requestFrameId && (frame.ok === false || frame.status === "error"))
82
+ refused = true;
83
+ });
84
+ try {
85
+ return typedResult(
86
+ await client.global(operation, input, {
87
+ onDispatch: ({ frame }) => {
88
+ requestFrameId = frame.id;
89
+ },
90
+ }),
91
+ );
92
+ } catch (error) {
93
+ if (refused && error instanceof SdkClientError)
94
+ return { ok: false, error: { code: error.code, message: error.message } };
95
+ throw error;
96
+ } finally {
97
+ unsubscribe();
98
+ }
99
+ }
100
+
66
101
  export async function prepareDoctorBrokerRestart(
67
102
  client: SdkClient,
68
103
  discovery: BrokerDiscovery,
@@ -74,7 +109,7 @@ export async function prepareDoctorBrokerRestart(
74
109
  deadlineAt: Date.now() + (options.deadlineMs ?? 30_000),
75
110
  drain: options.drain,
76
111
  };
77
- return typedResult(await client.global("broker.prepare_restart", { ...input }));
112
+ return await restartRequest(client, "broker.prepare_restart", { ...input });
78
113
  }
79
114
 
80
115
  export async function commitDoctorBrokerRestart(
@@ -90,11 +125,11 @@ export async function commitDoctorBrokerRestart(
90
125
  lease: prepared.lease,
91
126
  occupancyEpoch: prepared.occupancyEpoch,
92
127
  };
93
- return typedResult(await client.global("broker.commit_restart", { ...input }));
128
+ return await restartRequest(client, "broker.commit_restart", { ...input });
94
129
  }
95
130
 
96
131
  export async function cancelDoctorBrokerRestart(client: SdkClient, requestId: string): Promise<BrokerRestartResult> {
97
- return typedResult(await client.global("broker.cancel_restart", { requestId }));
132
+ return await restartRequest(client, "broker.cancel_restart", { requestId });
98
133
  }
99
134
 
100
135
  const OLD_OWNER_EXIT_POLL_MS = 50;