@gajae-code/coding-agent 0.18.0 → 0.18.1

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 (40) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/types/config/settings-schema.d.ts +2 -2
  3. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +1 -0
  4. package/dist/types/sdk/acp/adapter.d.ts +3 -1
  5. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  6. package/dist/types/sdk/client/client.d.ts +5 -1
  7. package/dist/types/session/agent-session.d.ts +2 -0
  8. package/dist/types/task/index.d.ts +0 -1
  9. package/dist/types/tools/descriptors.d.ts +5 -0
  10. package/dist/types/tools/eval-backends.d.ts +37 -0
  11. package/dist/types/tools/eval.d.ts +1 -6
  12. package/dist/types/tools/index.d.ts +1 -36
  13. package/dist/types/tools/session-descriptions.d.ts +22 -0
  14. package/package.json +7 -7
  15. package/scripts/generate-sdk-operation-inventory.ts +2 -0
  16. package/src/cli/doctor/managed-link.ts +4 -4
  17. package/src/cli/doctor/repairs.ts +9 -1
  18. package/src/config/settings-schema.ts +8 -2
  19. package/src/exec/bash-shell-guardian.ts +31 -15
  20. package/src/gjc-runtime/session-state-sidecar.ts +88 -58
  21. package/src/internal-urls/docs-index.generated.ts +4 -4
  22. package/src/modes/acp/acp-agent.ts +97 -16
  23. package/src/modes/interactive-mode.ts +10 -0
  24. package/src/modes/utils/context-usage.ts +2 -6
  25. package/src/prompts/tools/task.md +8 -27
  26. package/src/sdk/acp/adapter.ts +38 -3
  27. package/src/sdk/broker/doctor-restart.ts +38 -3
  28. package/src/sdk/bus/index.ts +3 -3
  29. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  30. package/src/sdk/client/client.ts +53 -35
  31. package/src/sdk/protocol/operation-inventory.generated.json +11 -0
  32. package/src/session/agent-session.ts +65 -15
  33. package/src/task/index.ts +21 -66
  34. package/src/tools/descriptors.ts +27 -1
  35. package/src/tools/eval-backends.ts +95 -0
  36. package/src/tools/eval.ts +3 -15
  37. package/src/tools/index.ts +9 -88
  38. package/src/tools/search.ts +2 -7
  39. package/src/tools/session-descriptions.ts +135 -0
  40. package/src/tools/tool-catalog.generated.ts +1 -1
@@ -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;
@@ -1495,6 +1507,11 @@ export function acpRequestFailure(error: unknown): unknown {
1495
1507
  const code = typeof error === "object" && error !== null && "code" in error ? error.code : undefined;
1496
1508
  if (typeof code !== "string") return error;
1497
1509
  const message = error instanceof Error ? error.message : code;
1510
+ // ACP clients render `data.details` after the JSON-RPC message. The MCP launch
1511
+ // diagnostic is already the complete additional message, so repeating it in
1512
+ // `details` produces `Internal error: X.: X.` on the wire.
1513
+ const duplicateMcpLaunchDetails =
1514
+ code === "unavailable" && message.startsWith("MCP server request failed to start (");
1498
1515
  // A prompt terminal additionally publishes its classification (issue #5615). The
1499
1516
  // spread is empty for every other error, so their payloads are byte-identical to
1500
1517
  // before. `code`/`details` and the JSON-RPC code itself are untouched either way:
@@ -1502,8 +1519,8 @@ export function acpRequestFailure(error: unknown): unknown {
1502
1519
  const promptFailure = promptFailureFromDirectError(error);
1503
1520
  const data =
1504
1521
  promptFailure !== undefined
1505
- ? { code, details: message, ...promptFailureWireData(promptFailure) }
1506
- : { code, details: message };
1522
+ ? { code, ...(duplicateMcpLaunchDetails ? {} : { details: message }), ...promptFailureWireData(promptFailure) }
1523
+ : { code, ...(duplicateMcpLaunchDetails ? {} : { details: message }) };
1507
1524
  // An abandoned prompt additionally publishes the plan it never finished (issue #5669).
1508
1525
  // Same rule as above: `code`/`details` and the JSON-RPC code are untouched, and an abandon
1509
1526
  // that observed no plan adds no keys, so its payload stays byte-identical to before.
@@ -2362,7 +2379,7 @@ export class AcpAgent implements Agent {
2362
2379
  this.#releaseRetiredPromptAcknowledgement(params.sessionId, waiter);
2363
2380
  return await response;
2364
2381
  }
2365
- if (record.cancelRequested && waiter.cancelAcknowledged) {
2382
+ if (record.cancelRequested && (waiter.cancelAcknowledged || waiter.cancelBeforeAdmission)) {
2366
2383
  this.#releaseRetiredPromptAcknowledgement(params.sessionId, waiter);
2367
2384
  await this.#settleCancelledPrompt(params.sessionId, record, waiter);
2368
2385
  return await response;
@@ -2385,7 +2402,7 @@ export class AcpAgent implements Agent {
2385
2402
  this.#releaseRetiredPromptAcknowledgement(params.sessionId, waiter);
2386
2403
  return await response;
2387
2404
  }
2388
- if (record.cancelRequested && waiter.cancelAcknowledged) {
2405
+ if (record.cancelRequested && (waiter.cancelAcknowledged || waiter.cancelBeforeAdmission)) {
2389
2406
  this.#releaseRetiredPromptAcknowledgement(params.sessionId, waiter);
2390
2407
  await this.#settleCancelledPrompt(params.sessionId, record, waiter);
2391
2408
  return await response;
@@ -2415,18 +2432,18 @@ export class AcpAgent implements Agent {
2415
2432
  record.adapter,
2416
2433
  );
2417
2434
  }
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
2435
  if (waiter.settled || record.activePrompt !== waiter) {
2424
2436
  return await response;
2425
2437
  }
2426
- if (record.cancelRequested && waiter.cancelAcknowledged) {
2438
+ if (record.cancelRequested && (waiter.cancelAcknowledged || waiter.cancelBeforeAdmission)) {
2427
2439
  await this.#settleCancelledPrompt(params.sessionId, record, waiter);
2428
2440
  return await response;
2429
2441
  }
2442
+ waiter.dispatched = true;
2443
+ // The turn is now dispatched to the host: this prompt owned the session's first turn,
2444
+ // so it — not a preflight rejection that threw before this point — is what settles
2445
+ // `firstPromptDone` in `prompt()` (review P2).
2446
+ if (retryReservation) retryReservation.admitted = true;
2430
2447
 
2431
2448
  waiter.acknowledgementPending = true;
2432
2449
  const promptAdapter = record.adapter;
@@ -2546,6 +2563,19 @@ export class AcpAgent implements Agent {
2546
2563
  this.#rememberSettledPromptCorrelation(params.sessionId, record, acknowledgementCorrelation);
2547
2564
  return await response;
2548
2565
  }
2566
+ if (waiter.cancelBeforeAdmission && record.cancelRequested) {
2567
+ const scope = waiter.cancelBeforeAdmissionScope ?? "turn";
2568
+ // Keep the intent until an acknowledged abort or the prompt terminal settles it.
2569
+ void this.cancel({
2570
+ sessionId: params.sessionId,
2571
+ _meta: { gjc: { abortScope: scope } },
2572
+ } as CancelNotification).catch(error =>
2573
+ logger.warn("ACP could not retry a cancel after prompt admission", {
2574
+ sessionId: params.sessionId,
2575
+ error: error instanceof Error ? error.message : String(error),
2576
+ }),
2577
+ );
2578
+ }
2549
2579
  // Frames held while ownership was unknown belong to this prompt only when the
2550
2580
  // acknowledgement proves their complete correlation matches exactly.
2551
2581
  const deferredActivityFrames = waiter.deferredActivityFrames.splice(0);
@@ -2691,7 +2721,10 @@ export class AcpAgent implements Agent {
2691
2721
  // return the semantically meaningful `cancelled` stop reason, so that Clients
2692
2722
  // can reliably confirm the cancellation." Surfacing the transport's `busy`
2693
2723
  // rejection instead would show the user a spurious error for their own cancel.
2694
- if (record.cancelRequested && waiter.cancelAcknowledged) {
2724
+ if (
2725
+ record.cancelRequested &&
2726
+ (waiter.cancelAcknowledged || (waiter.cancelBeforeAdmission && !waiter.acknowledged && !waiter.dispatched))
2727
+ ) {
2695
2728
  record.cancelRequested = false;
2696
2729
  // The client's turn is settled by the return; the advisory idle publication
2697
2730
  // must not gate it and must still be attempted so the running phase is
@@ -2715,6 +2748,8 @@ export class AcpAgent implements Agent {
2715
2748
  // `_meta.gjc.abortScope: "owned"` (or `GJC_ACP_ABORT_SCOPE=owned`).
2716
2749
  const scope = resolveAcpAbortScope(params._meta, process.env);
2717
2750
  const waiter = record.activePrompt;
2751
+ const waiterWasUnacknowledged = waiter !== undefined && !waiter.acknowledged;
2752
+ const waiterWasBeforeActivity = waiter !== undefined && !waiter.observedTurnActivity;
2718
2753
  if (waiter) {
2719
2754
  // Overlapping cancels must not lose an earlier successful
2720
2755
  // acknowledgement: ANY successful attempt resolves the shared
@@ -2733,9 +2768,27 @@ export class AcpAgent implements Agent {
2733
2768
  }
2734
2769
  }
2735
2770
  try {
2736
- const acknowledgement = await record.adapter.cancel(scope);
2771
+ let acknowledgement = await record.adapter.cancel(scope);
2772
+ let acknowledgementScope = scope;
2773
+ if (
2774
+ !isAbortAcknowledged(acknowledgement, scope) &&
2775
+ isAbortNoEffect(acknowledgement) &&
2776
+ (waiterWasUnacknowledged || waiterWasBeforeActivity) &&
2777
+ waiter &&
2778
+ record.activePrompt === waiter &&
2779
+ !waiter.settled
2780
+ ) {
2781
+ waiter.cancelBeforeAdmission = true;
2782
+ waiter.cancelBeforeAdmissionScope =
2783
+ waiter.cancelBeforeAdmissionScope === "owned" || scope === "owned" ? "owned" : "turn";
2784
+ if (waiter.acknowledged) {
2785
+ // The first abort raced prompt admission. Retry now that the turn is admitted.
2786
+ acknowledgementScope = waiter.cancelBeforeAdmissionScope;
2787
+ acknowledgement = await record.adapter.cancel(acknowledgementScope);
2788
+ }
2789
+ }
2737
2790
  const result = object(object(acknowledgement)?.result) ?? object(acknowledgement);
2738
- if (!isAbortAcknowledged(acknowledgement, scope))
2791
+ if (!isAbortAcknowledged(acknowledgement, acknowledgementScope))
2739
2792
  throw new AcpSdkAdapterError(
2740
2793
  "abort_unacknowledged",
2741
2794
  "SDK did not acknowledge cancellation of the active prompt.",
@@ -2846,7 +2899,12 @@ export class AcpAgent implements Agent {
2846
2899
  } finally {
2847
2900
  if (waiter) {
2848
2901
  waiter.pendingCancelAttempts = Math.max(0, (waiter.pendingCancelAttempts ?? 1) - 1);
2849
- if (waiter.pendingCancelAttempts === 0 && !waiter.cancelAcknowledged && record.activePrompt === waiter) {
2902
+ if (
2903
+ waiter.pendingCancelAttempts === 0 &&
2904
+ !waiter.cancelAcknowledged &&
2905
+ !waiter.cancelBeforeAdmission &&
2906
+ record.activePrompt === waiter
2907
+ ) {
2850
2908
  record.cancelRequested = false;
2851
2909
  }
2852
2910
  }
@@ -2869,8 +2927,9 @@ export class AcpAgent implements Agent {
2869
2927
 
2870
2928
  async #settleCancelledPrompt(id: string, record: SessionRecord, waiter: PromptWaiter): Promise<void> {
2871
2929
  // 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;
2930
+ // after an acknowledged abort or before turn dispatch for this waiter.
2931
+ const preAdmissionCancellation = waiter.cancelBeforeAdmission && !waiter.acknowledged && !waiter.dispatched;
2932
+ if (!waiter.cancelAcknowledged && !preAdmissionCancellation) return;
2874
2933
  if (this.#sessions.get(id) !== record || record.activePrompt !== waiter || waiter.settled) return;
2875
2934
  if (waiter.terminalReserved) return;
2876
2935
  // An overlapping acknowledged cancel does not supersede the earlier
@@ -2893,6 +2952,8 @@ export class AcpAgent implements Agent {
2893
2952
  if (!record.backgroundBusy) record.busy = false;
2894
2953
  clearPromptWatchdog(waiter);
2895
2954
  record.cancelRequested = false;
2955
+ waiter.cancelBeforeAdmission = false;
2956
+ waiter.cancelBeforeAdmissionScope = undefined;
2896
2957
  waiter.settled = true;
2897
2958
  this.#fenceRetiredPromptAcknowledgement(id, waiter);
2898
2959
  waiter.deferredFrames.length = 0;
@@ -4605,6 +4666,24 @@ export class AcpAgent implements Agent {
4605
4666
  }
4606
4667
  if (toolCallId && event.type === "tool_execution_end") record.toolArgs.delete(toolCallId);
4607
4668
  if (event.type === "agent_start") {
4669
+ if (
4670
+ promptOwner?.cancelBeforeAdmission &&
4671
+ record.cancelRequested &&
4672
+ !promptOwner.cancelAcknowledged &&
4673
+ !promptOwner.cancelAfterStartAbortIssued
4674
+ ) {
4675
+ promptOwner.cancelAfterStartAbortIssued = true;
4676
+ const scope = promptOwner.cancelBeforeAdmissionScope ?? "turn";
4677
+ void this.cancel({
4678
+ sessionId: id,
4679
+ _meta: { gjc: { abortScope: scope } },
4680
+ } as CancelNotification).catch(error =>
4681
+ logger.warn("ACP could not retry a cancel after prompt start", {
4682
+ sessionId: id,
4683
+ error: error instanceof Error ? error.message : String(error),
4684
+ }),
4685
+ );
4686
+ }
4608
4687
  await this.#publishSessionUpdate(
4609
4688
  id,
4610
4689
  {
@@ -4874,6 +4953,8 @@ export class AcpAgent implements Agent {
4874
4953
  record.activePrompt = undefined;
4875
4954
  clearPromptWatchdog(waiter);
4876
4955
  waiter.settled = true;
4956
+ waiter.cancelBeforeAdmission = false;
4957
+ waiter.cancelBeforeAdmissionScope = undefined;
4877
4958
  const { outcome } = waiter.terminal;
4878
4959
  this.#rememberSettledPromptCorrelation(id, record, waiter.correlation);
4879
4960
  if (outcome.kind === "stopped") {
@@ -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;
@@ -9636,10 +9636,10 @@ export function createNotificationsExtension(
9636
9636
  else if (finalAssistant?.errorKind === "provider_safety_stop")
9637
9637
  outcome = { kind: "stopped", reason: "refusal", provenance: "agent" };
9638
9638
  // A missing/normal final assistant with a non-failing loop stop reason is a
9639
- // normal turn end; only explicit error/aborted assistants or non-completed
9640
- // loop stop reasons are failures. Text is never parsed.
9639
+ // normal turn end; only explicit error/aborted assistants or other
9640
+ // non-completed loop stop reasons are failures. Text is never parsed.
9641
9641
  else if (
9642
- (event.stopReason === undefined || event.stopReason === "completed") &&
9642
+ (event.stopReason === undefined || event.stopReason === "completed" || event.stopReason === "paused") &&
9643
9643
  finalAssistant?.stopReason !== "error" &&
9644
9644
  finalAssistant?.stopReason !== "aborted"
9645
9645
  )
@@ -297,7 +297,7 @@ export const SDK_LIFECYCLE_ROUTER_PROTOCOL_VERSION = 1;
297
297
  * Generation 193 gates streamed content on negotiated observer capabilities,
298
298
  * so existing owners are replaced before exposing the new observer path.
299
299
  */
300
- export const DAEMON_GENERATION = 196;
300
+ export const DAEMON_GENERATION = 197;
301
301
 
302
302
  /**
303
303
  * Serving-compatibility boundary for daemon lifecycle requests. Epoch 7