@oh-my-pi/pi-coding-agent 17.0.0 → 17.0.2

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 (202) hide show
  1. package/CHANGELOG.md +128 -0
  2. package/dist/cli.js +4006 -3966
  3. package/dist/types/advisor/advise-tool.d.ts +5 -2
  4. package/dist/types/advisor/config.d.ts +14 -6
  5. package/dist/types/advisor/runtime.d.ts +20 -11
  6. package/dist/types/autolearn/controller.d.ts +1 -0
  7. package/dist/types/cli-commands.d.ts +14 -1
  8. package/dist/types/config/model-discovery.d.ts +17 -0
  9. package/dist/types/config/model-registry.d.ts +4 -0
  10. package/dist/types/config/model-resolver.d.ts +6 -2
  11. package/dist/types/config/models-config-schema.d.ts +10 -0
  12. package/dist/types/config/models-config.d.ts +6 -0
  13. package/dist/types/config/settings-schema.d.ts +32 -7
  14. package/dist/types/config/settings.d.ts +50 -0
  15. package/dist/types/cursor.d.ts +11 -0
  16. package/dist/types/discovery/helpers.d.ts +5 -2
  17. package/dist/types/discovery/substitute-plugin-root.d.ts +20 -7
  18. package/dist/types/exec/bash-executor.d.ts +2 -0
  19. package/dist/types/extensibility/extensions/managed-timers.d.ts +15 -0
  20. package/dist/types/extensibility/extensions/runner.d.ts +7 -0
  21. package/dist/types/extensibility/extensions/types.d.ts +18 -0
  22. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +40 -0
  23. package/dist/types/extensibility/shared-events.d.ts +6 -0
  24. package/dist/types/extensibility/utils.d.ts +2 -2
  25. package/dist/types/internal-urls/agent-protocol.d.ts +1 -0
  26. package/dist/types/lib/xai-http.d.ts +11 -0
  27. package/dist/types/mcp/transports/stdio.d.ts +13 -1
  28. package/dist/types/modes/acp/acp-agent.d.ts +3 -0
  29. package/dist/types/modes/acp/acp-mode.d.ts +6 -2
  30. package/dist/types/modes/components/advisor-config.d.ts +8 -1
  31. package/dist/types/modes/components/assistant-message.d.ts +2 -0
  32. package/dist/types/modes/components/custom-editor-plugin-ctor.test.d.ts +1 -0
  33. package/dist/types/modes/components/custom-editor.d.ts +23 -1
  34. package/dist/types/modes/components/hook-editor.d.ts +16 -2
  35. package/dist/types/modes/components/login-dialog.test.d.ts +1 -0
  36. package/dist/types/modes/components/model-hub.d.ts +5 -2
  37. package/dist/types/modes/components/session-selector.d.ts +4 -0
  38. package/dist/types/modes/components/tool-execution.d.ts +8 -1
  39. package/dist/types/modes/components/tool-execution.test.d.ts +1 -0
  40. package/dist/types/modes/controllers/command-controller.d.ts +8 -0
  41. package/dist/types/modes/controllers/selector-controller.d.ts +3 -1
  42. package/dist/types/modes/interactive-mode.d.ts +6 -0
  43. package/dist/types/modes/noninteractive-dispose.test.d.ts +1 -0
  44. package/dist/types/modes/print-mode.d.ts +1 -1
  45. package/dist/types/modes/types.d.ts +10 -0
  46. package/dist/types/modes/utils/transcript-render-helpers.d.ts +2 -2
  47. package/dist/types/modes/warp-events.d.ts +24 -0
  48. package/dist/types/modes/warp-events.test.d.ts +1 -0
  49. package/dist/types/plan-mode/model-transition.d.ts +47 -0
  50. package/dist/types/plan-mode/model-transition.test.d.ts +1 -0
  51. package/dist/types/registry/agent-lifecycle.d.ts +26 -1
  52. package/dist/types/sdk.d.ts +13 -2
  53. package/dist/types/session/agent-session.d.ts +43 -11
  54. package/dist/types/session/session-history-format.d.ts +10 -0
  55. package/dist/types/session/session-manager.d.ts +14 -0
  56. package/dist/types/session/streaming-output.d.ts +2 -0
  57. package/dist/types/slash-commands/helpers/active-oauth-account.d.ts +9 -0
  58. package/dist/types/slash-commands/types.d.ts +4 -4
  59. package/dist/types/system-prompt.d.ts +1 -1
  60. package/dist/types/task/label.d.ts +1 -1
  61. package/dist/types/telemetry-export.d.ts +34 -9
  62. package/dist/types/tools/approval.d.ts +8 -0
  63. package/dist/types/tools/bash.d.ts +2 -0
  64. package/dist/types/tools/browser/registry.d.ts +7 -3
  65. package/dist/types/tools/browser/tab-supervisor.d.ts +2 -0
  66. package/dist/types/tools/essential-tools.d.ts +29 -0
  67. package/dist/types/tools/image-gen.d.ts +2 -1
  68. package/dist/types/tools/index.d.ts +4 -1
  69. package/dist/types/tools/manage-skill.d.ts +2 -0
  70. package/dist/types/tools/xdev.d.ts +11 -2
  71. package/dist/types/utils/title-generator.d.ts +2 -1
  72. package/dist/types/web/search/index.d.ts +2 -0
  73. package/dist/types/web/search/providers/base.d.ts +3 -0
  74. package/dist/types/web/search/providers/kimi.d.ts +4 -1
  75. package/dist/types/web/search/types.d.ts +1 -1
  76. package/package.json +21 -16
  77. package/src/advisor/__tests__/advisor.test.ts +1358 -86
  78. package/src/advisor/__tests__/config.test.ts +58 -2
  79. package/src/advisor/advise-tool.ts +7 -3
  80. package/src/advisor/config.ts +76 -24
  81. package/src/advisor/runtime.ts +445 -92
  82. package/src/autolearn/controller.ts +23 -28
  83. package/src/cli/bench-cli.ts +4 -1
  84. package/src/cli/grep-cli.ts +2 -1
  85. package/src/cli-commands.ts +57 -23
  86. package/src/cli.ts +5 -1
  87. package/src/config/model-discovery.ts +81 -21
  88. package/src/config/model-registry.ts +34 -6
  89. package/src/config/model-resolver.ts +57 -12
  90. package/src/config/models-config-schema.ts +1 -0
  91. package/src/config/settings-schema.ts +43 -6
  92. package/src/config/settings.ts +405 -25
  93. package/src/cursor.ts +20 -3
  94. package/src/debug/report-bundle.ts +40 -4
  95. package/src/discovery/codex.ts +14 -4
  96. package/src/discovery/helpers.ts +28 -5
  97. package/src/discovery/substitute-plugin-root.ts +23 -7
  98. package/src/exec/bash-executor.ts +14 -5
  99. package/src/export/html/template.js +2 -0
  100. package/src/extensibility/custom-tools/loader.ts +3 -3
  101. package/src/extensibility/custom-tools/wrapper.ts +2 -1
  102. package/src/extensibility/extensions/loader.ts +3 -3
  103. package/src/extensibility/extensions/managed-timers.ts +83 -0
  104. package/src/extensibility/extensions/runner.ts +26 -0
  105. package/src/extensibility/extensions/types.ts +18 -0
  106. package/src/extensibility/extensions/wrapper.ts +2 -1
  107. package/src/extensibility/hooks/loader.ts +3 -3
  108. package/src/extensibility/legacy-pi-coding-agent-shim.ts +96 -1
  109. package/src/extensibility/plugins/legacy-pi-compat.ts +225 -22
  110. package/src/extensibility/plugins/manager.ts +2 -2
  111. package/src/extensibility/shared-events.ts +6 -0
  112. package/src/extensibility/utils.ts +91 -25
  113. package/src/internal-urls/__tests__/agent-protocol-nested.test.ts +73 -0
  114. package/src/internal-urls/agent-protocol.ts +73 -39
  115. package/src/irc/bus.ts +22 -3
  116. package/src/launch/broker.ts +3 -2
  117. package/src/launch/client.ts +2 -2
  118. package/src/launch/presence.ts +2 -2
  119. package/src/lib/xai-http.ts +28 -2
  120. package/src/lsp/client.ts +1 -1
  121. package/src/main.ts +11 -8
  122. package/src/mcp/manager.ts +9 -3
  123. package/src/mcp/transports/stdio.ts +103 -23
  124. package/src/modes/acp/acp-agent.ts +28 -9
  125. package/src/modes/acp/acp-event-mapper.ts +8 -0
  126. package/src/modes/acp/acp-mode.ts +18 -4
  127. package/src/modes/components/advisor-config.ts +65 -3
  128. package/src/modes/components/ask-dialog.ts +1 -1
  129. package/src/modes/components/assistant-message.ts +35 -14
  130. package/src/modes/components/chat-transcript-builder.ts +2 -0
  131. package/src/modes/components/custom-editor-plugin-ctor.test.ts +36 -0
  132. package/src/modes/components/custom-editor.ts +62 -1
  133. package/src/modes/components/hook-editor.ts +35 -5
  134. package/src/modes/components/login-dialog.test.ts +56 -0
  135. package/src/modes/components/login-dialog.ts +7 -3
  136. package/src/modes/components/model-hub.ts +138 -42
  137. package/src/modes/components/plan-review-overlay.ts +51 -10
  138. package/src/modes/components/session-selector.ts +10 -0
  139. package/src/modes/components/status-line/component.test.ts +1 -0
  140. package/src/modes/components/status-line/component.ts +2 -4
  141. package/src/modes/components/status-line/segments.ts +21 -6
  142. package/src/modes/components/tool-execution.test.ts +101 -0
  143. package/src/modes/components/tool-execution.ts +117 -9
  144. package/src/modes/components/transcript-container.ts +30 -2
  145. package/src/modes/controllers/command-controller-shared.ts +1 -1
  146. package/src/modes/controllers/command-controller.ts +168 -48
  147. package/src/modes/controllers/event-controller.ts +6 -0
  148. package/src/modes/controllers/extension-ui-controller.ts +4 -22
  149. package/src/modes/controllers/input-controller.ts +12 -12
  150. package/src/modes/controllers/selector-controller.ts +208 -46
  151. package/src/modes/interactive-mode.ts +205 -65
  152. package/src/modes/noninteractive-dispose.test.ts +60 -0
  153. package/src/modes/print-mode.ts +10 -5
  154. package/src/modes/rpc/host-tools.ts +2 -1
  155. package/src/modes/rpc/rpc-mode.ts +31 -7
  156. package/src/modes/theme/theme.ts +2 -2
  157. package/src/modes/types.ts +10 -0
  158. package/src/modes/utils/interactive-context-helpers.ts +3 -1
  159. package/src/modes/utils/transcript-render-helpers.ts +3 -2
  160. package/src/modes/warp-events.test.ts +794 -0
  161. package/src/modes/warp-events.ts +232 -0
  162. package/src/plan-mode/model-transition.test.ts +60 -0
  163. package/src/plan-mode/model-transition.ts +51 -0
  164. package/src/prompts/system/system-prompt.md +1 -1
  165. package/src/registry/agent-lifecycle.ts +133 -18
  166. package/src/sdk.ts +227 -44
  167. package/src/session/agent-session.ts +1364 -382
  168. package/src/session/session-history-format.ts +20 -5
  169. package/src/session/session-listing.ts +6 -5
  170. package/src/session/session-loader.ts +9 -0
  171. package/src/session/session-manager.ts +48 -0
  172. package/src/session/session-persistence.ts +11 -0
  173. package/src/session/streaming-output.ts +3 -1
  174. package/src/slash-commands/builtin-registry.ts +9 -0
  175. package/src/slash-commands/helpers/active-oauth-account.ts +16 -0
  176. package/src/slash-commands/types.ts +4 -4
  177. package/src/system-prompt.ts +2 -2
  178. package/src/task/executor.ts +1 -1
  179. package/src/task/label.ts +2 -0
  180. package/src/telemetry-export.ts +453 -97
  181. package/src/tools/approval.ts +11 -0
  182. package/src/tools/bash.ts +71 -38
  183. package/src/tools/browser/registry.ts +9 -2
  184. package/src/tools/browser/tab-supervisor.ts +65 -10
  185. package/src/tools/browser.ts +4 -3
  186. package/src/tools/essential-tools.ts +45 -0
  187. package/src/tools/eval.ts +3 -0
  188. package/src/tools/gh.ts +169 -2
  189. package/src/tools/image-gen.ts +624 -513
  190. package/src/tools/index.ts +10 -6
  191. package/src/tools/manage-skill.ts +5 -3
  192. package/src/tools/output-meta.ts +7 -0
  193. package/src/tools/path-utils.ts +8 -5
  194. package/src/tools/read.ts +48 -3
  195. package/src/tools/write.ts +22 -4
  196. package/src/tools/xdev.ts +14 -3
  197. package/src/utils/title-generator.ts +15 -4
  198. package/src/web/search/index.ts +21 -6
  199. package/src/web/search/providers/base.ts +3 -0
  200. package/src/web/search/providers/kimi.ts +18 -12
  201. package/src/web/search/providers/xai.ts +40 -9
  202. package/src/web/search/types.ts +6 -1
@@ -155,6 +155,7 @@ import {
155
155
  type AdvisorNote,
156
156
  AdvisorOutputQuarantinedError,
157
157
  AdvisorRuntime,
158
+ type AdvisorRuntimeStatus,
158
159
  type AdvisorSeverity,
159
160
  AdvisorTranscriptRecorder,
160
161
  advisorTranscriptFilename,
@@ -170,6 +171,7 @@ import {
170
171
  } from "../advisor";
171
172
  import { type AsyncJob, type AsyncJobDeliveryState, AsyncJobManager } from "../async";
172
173
  import { classifyDifficulty } from "../auto-thinking/classifier";
174
+ import { reset as resetCapabilities } from "../capability";
173
175
  import type { Rule } from "../capability/rule";
174
176
  import { shouldEnableAppendOnlyContext } from "../config/append-only-context-mode";
175
177
  import type { ModelRegistry } from "../config/model-registry";
@@ -196,6 +198,7 @@ import {
196
198
  onModelRolesChanged,
197
199
  validateProviderMaxInFlightRequests,
198
200
  } from "../config/settings";
201
+ import { CursorExecHandlers } from "../cursor";
199
202
  import { RawSseDebugBuffer } from "../debug/raw-sse-buffer";
200
203
  import { expandApplyPatchToEntries, normalizeDiff, normalizeToLF, ParseError, previewPatch, stripBom } from "../edit";
201
204
  import { getFileSnapshotStore } from "../edit/file-snapshot-store";
@@ -232,12 +235,13 @@ import type {
232
235
  TurnEndEvent,
233
236
  TurnStartEvent,
234
237
  } from "../extensibility/extensions";
238
+ import { ManagedTimers } from "../extensibility/extensions/managed-timers";
235
239
  import { createExtensionModelQuery } from "../extensibility/extensions/model-api";
236
240
  import type { CompactOptions, ContextUsage } from "../extensibility/extensions/types";
237
241
  import { ExtensionToolWrapper } from "../extensibility/extensions/wrapper";
238
242
  import type { HookCommandContext } from "../extensibility/hooks/types";
239
243
  import type { RecoveredRetryError } from "../extensibility/shared-events";
240
- import type { Skill, SkillWarning } from "../extensibility/skills";
244
+ import { loadSkills, type Skill, type SkillWarning, setActiveSkills } from "../extensibility/skills";
241
245
  import { expandSlashCommand, type FileSlashCommand } from "../extensibility/slash-commands";
242
246
  import { GoalRuntime } from "../goals/runtime";
243
247
  import type { Goal, GoalModeState } from "../goals/state";
@@ -251,7 +255,11 @@ import { containsOrchestrate, ORCHESTRATE_NOTICE } from "../modes/orchestrate";
251
255
  import { theme } from "../modes/theme/theme";
252
256
  import { parseTurnBudget } from "../modes/turn-budget";
253
257
  import { containsUltrathink, ULTRATHINK_NOTICE } from "../modes/ultrathink";
254
- import { computeNonMessageBreakdown, computeNonMessageTokens } from "../modes/utils/context-usage";
258
+ import {
259
+ computeNonMessageBreakdown,
260
+ computeNonMessageTokens,
261
+ estimateToolSchemaTokens,
262
+ } from "../modes/utils/context-usage";
255
263
  import { containsWorkflow, renderWorkflowNotice } from "../modes/workflow";
256
264
  import { resolveApprovedPlan } from "../plan-mode/approved-plan";
257
265
  import { createPlanReadMatcher } from "../plan-mode/plan-protection";
@@ -314,7 +322,7 @@ import { releaseTabsForOwner } from "../tools/browser/tab-supervisor";
314
322
  import { isMCPToolName, normalizeToolNames } from "../tools/builtin-names";
315
323
  import type { CheckpointState, CompletedRewindState } from "../tools/checkpoint";
316
324
  import { outputMeta, wrapToolWithMetaNotice } from "../tools/output-meta";
317
- import { normalizeLocalScheme, resolveToCwd } from "../tools/path-utils";
325
+ import { isInternalUrlPath, normalizeLocalScheme, resolveToCwd } from "../tools/path-utils";
318
326
  import {
319
327
  buildResolveReminderMessage,
320
328
  isPreviewResolutionToolCall,
@@ -392,6 +400,34 @@ import { YieldQueue } from "./yield-queue";
392
400
  const SESSION_STOP_CONTINUATION_CAP = 8;
393
401
  const PLAN_MODE_REMINDER_MAX = 3;
394
402
 
403
+ type BashAppendDestination =
404
+ | { kind: "current"; manager: SessionManager }
405
+ | { kind: "detached"; manager: SessionManager }
406
+ | { kind: "branch"; manager: SessionManager; parentId: string | null };
407
+
408
+ interface BashSessionTarget {
409
+ sessionId: string;
410
+ refs: number;
411
+ destination?: BashAppendDestination;
412
+ pending?: Promise<BashAppendDestination>;
413
+ }
414
+
415
+ interface PendingBashMessage {
416
+ target: BashSessionTarget;
417
+ message: BashExecutionMessage;
418
+ }
419
+
420
+ interface BashSessionTransition {
421
+ oldTarget: BashSessionTarget;
422
+ newTarget: BashSessionTarget;
423
+ oldSessionId: string;
424
+ oldSessionFile: string | undefined;
425
+ oldLeafId: string | null;
426
+ detachedManager: SessionManager | undefined;
427
+ resolveOld: ((destination: BashAppendDestination) => void) | undefined;
428
+ resolveNew: (destination: BashAppendDestination) => void;
429
+ }
430
+
395
431
  /**
396
432
  * Mutating tool results (`bash`/`eval`/`edit`/`write`/`ast_edit`) without the
397
433
  * agent touching the `todo` tool that trip the mid-run reconciliation nudge.
@@ -530,6 +566,43 @@ function reportFromRewindReportContent(content: string): string {
530
566
  return report.trim();
531
567
  }
532
568
 
569
+ type SemanticCheckpointToolName = "checkpoint" | "rewind";
570
+
571
+ type SemanticToolResult = {
572
+ toolName: SemanticCheckpointToolName;
573
+ details?: unknown;
574
+ };
575
+
576
+ /**
577
+ * Normalize checkpoint/rewind results across native calls and `write xd://`
578
+ * dispatches. Xdev keeps the wrapped tool's result details under `xdev.inner`,
579
+ * while direct calls put them on the result itself.
580
+ */
581
+ function semanticToolResult(toolName: string | undefined, result: unknown): SemanticToolResult | undefined {
582
+ if (toolName === "checkpoint" || toolName === "rewind") {
583
+ const details = result && typeof result === "object" && "details" in result ? result.details : undefined;
584
+ return { toolName, details };
585
+ }
586
+ const dispatch = writeDeviceDispatch(toolName ?? "", result);
587
+ if (dispatch?.mode !== "execute" || (dispatch.tool !== "checkpoint" && dispatch.tool !== "rewind")) {
588
+ return undefined;
589
+ }
590
+ return { toolName: dispatch.tool, details: dispatch.inner };
591
+ }
592
+
593
+ function isTodoPhase(value: unknown): value is TodoPhase {
594
+ if (!isRecord(value) || typeof value.name !== "string" || !Array.isArray(value.tasks)) return false;
595
+ return value.tasks.every(
596
+ task =>
597
+ isRecord(task) &&
598
+ typeof task.content === "string" &&
599
+ (task.status === "pending" ||
600
+ task.status === "in_progress" ||
601
+ task.status === "completed" ||
602
+ task.status === "abandoned"),
603
+ );
604
+ }
605
+
533
606
  function completedRewindFromEntry(entry: SessionEntry): CompletedRewindState | undefined {
534
607
  if (entry.type !== "custom_message" || entry.customType !== "rewind-report") return undefined;
535
608
  const details = entry.details;
@@ -542,21 +615,18 @@ function completedRewindFromEntry(entry: SessionEntry): CompletedRewindState | u
542
615
  reportFromRewindReportContent(customMessageContentText(entry.content));
543
616
  return report.length > 0 ? { report, startedAt, rewoundAt } : undefined;
544
617
  }
545
-
546
- function isSuccessfulCheckpointEntry(entry: SessionEntry): entry is SessionMessageEntry & {
547
- message: { role: "toolResult"; toolName: "checkpoint"; isError?: false };
548
- } {
549
- return (
550
- entry.type === "message" &&
551
- entry.message.role === "toolResult" &&
552
- entry.message.toolName === "checkpoint" &&
553
- entry.message.isError !== true
554
- );
618
+ function isSuccessfulCheckpointEntry(
619
+ entry: SessionEntry,
620
+ ): entry is SessionEntry & { type: "message"; message: Extract<AgentMessage, { role: "toolResult" }> } {
621
+ if (entry.type !== "message" || entry.message.role !== "toolResult" || entry.message.isError === true) {
622
+ return false;
623
+ }
624
+ return semanticToolResult(entry.message.toolName, entry.message)?.toolName === "checkpoint";
555
625
  }
556
626
 
557
627
  function checkpointStartedAtFromEntry(entry: SessionEntry): string | undefined {
558
628
  if (!isSuccessfulCheckpointEntry(entry)) return undefined;
559
- const details = entry.message.details;
629
+ const details = semanticToolResult(entry.message.toolName, entry.message)?.details;
560
630
  if (details && typeof details === "object") {
561
631
  const startedAt = stringProperty(details, "startedAt");
562
632
  if (startedAt) return startedAt;
@@ -670,7 +740,7 @@ const COMPACTION_CHECK_NONE: CompactionCheckResult = {
670
740
  };
671
741
  const COMPACTION_CHECK_DEFERRED_HANDOFF: CompactionCheckResult = {
672
742
  deferredHandoff: true,
673
- continuationScheduled: true,
743
+ continuationScheduled: false,
674
744
  };
675
745
  const COMPACTION_CHECK_CONTINUATION: CompactionCheckResult = {
676
746
  deferredHandoff: false,
@@ -827,6 +897,8 @@ export interface AgentSessionConfig {
827
897
  skills?: Skill[];
828
898
  /** Skill loading warnings (already captured by SDK) */
829
899
  skillWarnings?: SkillWarning[];
900
+ /** Whether runtime reloads may rediscover disk-backed skills for this session. */
901
+ skillsReloadable?: boolean;
830
902
  /** Custom commands (TypeScript slash commands) */
831
903
  customCommands?: LoadedCustomCommand[];
832
904
  skillsSettings?: SkillsSettings;
@@ -840,6 +912,8 @@ export interface AgentSessionConfig {
840
912
  builtInToolNames?: Iterable<string>;
841
913
  /** Update tool-session predicates that render guidance from the live active tool set. */
842
914
  setActiveToolNames?: (names: Iterable<string>) => void;
915
+ /** Register the write transport lazily when runtime xdev mounts first need it. */
916
+ ensureWriteRegistered?: () => Promise<boolean>;
843
917
  /** Current session pre-LLM message transform pipeline */
844
918
  transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => AgentMessage[] | Promise<AgentMessage[]>;
845
919
  /**
@@ -886,7 +960,8 @@ export interface AgentSessionConfig {
886
960
  xdevRegistry?: XdevRegistry;
887
961
  /** Discoverable tools mounted under `xd://` in the initial enabled set (startup partition in `sdk.ts`). */
888
962
  initialMountedXdevToolNames?: string[];
889
- requestedToolNames?: ReadonlySet<string>;
963
+ /** Explicit/effective names pinned top-level during runtime repartitioning. */
964
+ presentationPinnedToolNames?: ReadonlySet<string>;
890
965
  /**
891
966
  * Optional accessor for live MCP server instructions. Read by the session's
892
967
  * `rebuildSystemPrompt`-skip optimization to detect server-side instruction
@@ -1114,15 +1189,20 @@ export interface AdvisorStats {
1114
1189
  advisors: PerAdvisorStat[];
1115
1190
  }
1116
1191
 
1117
- /** One advisor's slice of {@link AdvisorStats}, surfaced for the multi-advisor status panel. */
1192
+ /** One advisor's slice of {@link AdvisorStats}. Active advisors carry full
1193
+ * token/cost data; disabled/no-model/quota-exhausted advisors appear with
1194
+ * just `name` + `status` so the status line can render a dot for every
1195
+ * configured advisor. */
1118
1196
  export interface PerAdvisorStat {
1119
1197
  name: string;
1120
- model: Model;
1198
+ status: AdvisorRuntimeStatus;
1199
+ model?: Model;
1121
1200
  contextWindow: number;
1122
1201
  contextTokens: number;
1123
1202
  tokens: AdvisorStats["tokens"];
1124
1203
  cost: number;
1125
1204
  messages: AdvisorStats["messages"];
1205
+ sessionId?: string;
1126
1206
  }
1127
1207
 
1128
1208
  /**
@@ -1131,6 +1211,13 @@ export interface PerAdvisorStat {
1131
1211
  * primary-scoped state (turn counters, interrupt latches, the shared yield
1132
1212
  * channel) stays on the session.
1133
1213
  */
1214
+ interface AdvisorRetryFallbackState {
1215
+ role: string;
1216
+ originalSelector: string;
1217
+ originalThinkingLevel: ThinkingLevel;
1218
+ lastAppliedThinkingLevel: ThinkingLevel;
1219
+ }
1220
+
1134
1221
  interface ActiveAdvisor {
1135
1222
  /** Display name from config ("default" for the legacy no-YAML advisor). */
1136
1223
  name: string;
@@ -1147,10 +1234,23 @@ interface ActiveAdvisor {
1147
1234
  agentUnsubscribe?: () => void;
1148
1235
  model: Model;
1149
1236
  thinkingLevel: ThinkingLevel;
1237
+ /** Provider credential/session identity retained across advisor model switches. */
1238
+ providerSessionId: string | undefined;
1239
+ /** Active chain state retained until the configured primary can be restored. */
1240
+ retryFallback?: AdvisorRetryFallbackState;
1241
+ /** A switched advisor model has not yet completed its first successful turn. */
1242
+ retryFallbackPendingSuccess: boolean;
1150
1243
  /** Stable key for the resolved runtime inputs that require a rebuild to change. */
1151
1244
  signature: string;
1152
1245
  }
1153
1246
 
1247
+ /** Runtime-only advisor compaction metadata. It never enters the model-facing summary text. */
1248
+ interface AdvisorCompactionSummaryMessage extends CompactionSummaryMessage {
1249
+ firstKeptEntryId?: string;
1250
+ /** First message index eligible to anchor provider usage after this compaction. */
1251
+ advisorUsageAnchorStartIndex?: number;
1252
+ }
1253
+
1154
1254
  /** Resolved advisor config ready to instantiate as an {@link ActiveAdvisor}. */
1155
1255
  interface AdvisorRuntimeDescriptor {
1156
1256
  config: AdvisorConfig;
@@ -1226,13 +1326,31 @@ function isRetryFallbackModelKey(key: string): boolean {
1226
1326
  }
1227
1327
 
1228
1328
  /**
1229
- * A `provider/*` fallback-chain key: matches any active model of that provider,
1230
- * so one entry covers every current and future model behind the provider.
1329
+ * A wildcard fallback-chain key/entry: `provider/*` matches any model of that
1330
+ * provider; an id-prefixed `provider/prefix/*` (e.g. `openrouter/google/*`)
1331
+ * scopes it to ids under that prefix — aggregators namespace model ids by
1332
+ * upstream vendor.
1231
1333
  */
1232
1334
  function isRetryFallbackWildcardKey(key: string): boolean {
1233
1335
  return key.endsWith("/*");
1234
1336
  }
1235
1337
 
1338
+ /**
1339
+ * Split a `…/*` wildcard key/entry into its provider and optional id prefix
1340
+ * (`google-vertex/*` → provider only; `openrouter/google/*` → provider
1341
+ * `openrouter`, prefix `google`). A template that names a known provider in
1342
+ * full wins over the split, so provider ids containing `/` keep working.
1343
+ */
1344
+ function parseRetryFallbackWildcard(
1345
+ key: string,
1346
+ isKnownProvider: (provider: string) => boolean,
1347
+ ): { provider: string; idPrefix: string | undefined } {
1348
+ const template = key.slice(0, -2);
1349
+ const slash = template.indexOf("/");
1350
+ if (slash < 0 || isKnownProvider(template)) return { provider: template, idPrefix: undefined };
1351
+ return { provider: template.slice(0, slash), idPrefix: template.slice(slash + 1) };
1352
+ }
1353
+
1236
1354
  function formatRetryFallbackSelector(model: Model, thinkingLevel: ThinkingLevel | undefined): string {
1237
1355
  return formatModelSelectorValue(formatModelStringWithRouting(model), thinkingLevel);
1238
1356
  }
@@ -1536,15 +1654,20 @@ function queuedTextContent(message: AgentMessage): string | undefined {
1536
1654
  if (!("content" in message)) return undefined;
1537
1655
  const content = message.content;
1538
1656
  if (typeof content === "string") return content;
1539
- return content.find((part): part is TextContent => part.type === "text")?.text;
1657
+ for (const part of content) {
1658
+ if (part.type === "text") return part.text;
1659
+ }
1660
+ return undefined;
1540
1661
  }
1541
1662
 
1542
1663
  function queuedImageContent(message: AgentMessage): ImageContent[] | undefined {
1543
1664
  if (!("content" in message) || typeof message.content === "string") return undefined;
1544
- const images = message.content.filter(
1545
- (part): part is ImageContent =>
1546
- part.type === "image" && typeof part.data === "string" && typeof part.mimeType === "string",
1547
- );
1665
+ const images: ImageContent[] = [];
1666
+ for (const part of message.content) {
1667
+ if (part.type === "image" && typeof part.data === "string" && typeof part.mimeType === "string") {
1668
+ images.push(part);
1669
+ }
1670
+ }
1548
1671
  return images.length > 0 ? images : undefined;
1549
1672
  }
1550
1673
 
@@ -1789,6 +1912,11 @@ export class AgentSession {
1789
1912
  #advisors: ActiveAdvisor[] = [];
1790
1913
  /** Configured advisor roster from WATCHDOG.yml; undefined/empty → single legacy advisor. */
1791
1914
  #advisorConfigs?: AdvisorConfig[];
1915
+ /** Per-advisor runtime status (slug → {name, status}). Tracks disabled/quota/states
1916
+ * for the configured roster even when the advisor has no live runtime. The name
1917
+ * is stored alongside the status so {@link getAdvisorStats} doesn't need to
1918
+ * recompute slugs or resolve config names. */
1919
+ #advisorStatuses: Map<string, { name: string; status: AdvisorRuntimeStatus }> = new Map();
1792
1920
  /** Provider-facing UUIDv7 identities keyed by primary provider session and advisor slug. */
1793
1921
  #advisorProviderSessionIds = new Map<string, string>();
1794
1922
  /** Aggregate of the most recent stop's recorder closes; awaited by dispose() and
@@ -1852,11 +1980,13 @@ export class AgentSession {
1852
1980
  * generation path. Refresh via {@link AgentSession.setTitleSystemPrompt} when
1853
1981
  * the session cwd changes. */
1854
1982
  #titleSystemPrompt: string | undefined;
1983
+ #titleGenerationAbortController = new AbortController();
1855
1984
  #toolChoiceQueue = new ToolChoiceQueue();
1856
1985
 
1857
1986
  // Bash execution state
1858
1987
  #bashAbortControllers = new Set<AbortController>();
1859
- #pendingBashMessages: BashExecutionMessage[] = [];
1988
+ #pendingBashMessages: PendingBashMessage[] = [];
1989
+ #bashSessionTarget!: BashSessionTarget;
1860
1990
 
1861
1991
  // Python execution state
1862
1992
  #evalAbortControllers = new Set<AbortController>();
@@ -1890,9 +2020,17 @@ export class AgentSession {
1890
2020
  #providerSessionId: string | undefined;
1891
2021
  #freshProviderSessionId: string | undefined;
1892
2022
  #inheritedProviderPromptCacheKey: string | undefined;
2023
+ #autolearnCaptureAbortController: AbortController | undefined;
2024
+ #autolearnCaptureTask: Promise<void> | undefined;
1893
2025
  #isDisposed = false;
1894
2026
  // Extension system
1895
2027
  #extensionRunner: ExtensionRunner | undefined = undefined;
2028
+ /**
2029
+ * Backs `ctx.setInterval`/`setTimeout`/`clearTimer` for the runner-less
2030
+ * command-context fallback (SDK embeddings with no extension runner). Lazily
2031
+ * created; cleared on dispose alongside the runner's own timers (#5664).
2032
+ */
2033
+ #fallbackExtensionTimers: ManagedTimers | undefined = undefined;
1896
2034
  #turnIndex = 0;
1897
2035
  #messageEndPersistenceTail: Promise<void> = Promise.resolve();
1898
2036
  #pendingMessageEndPersistence = new Map<string, Promise<void>>();
@@ -1907,6 +2045,7 @@ export class AgentSession {
1907
2045
  #mcpPromptCommands: LoadedCustomCommand[] = [];
1908
2046
 
1909
2047
  #skillsSettings: SkillsSettings | undefined;
2048
+ #skillsReloadable: boolean;
1910
2049
 
1911
2050
  // Model registry for API key resolution
1912
2051
  #modelRegistry: ModelRegistry;
@@ -1930,8 +2069,10 @@ export class AgentSession {
1930
2069
  #getLocalCalendarDate: () => string;
1931
2070
  #getMcpServerInstructions: (() => Map<string, string> | undefined) | undefined;
1932
2071
  #setActiveToolNames: ((names: Iterable<string>) => void) | undefined;
2072
+ #ensureWriteRegistered: (() => Promise<boolean>) | undefined;
1933
2073
  #disconnectOwnedMcpManager: (() => Promise<void>) | undefined;
1934
- #requestedToolNames: ReadonlySet<string> | undefined;
2074
+ #presentationPinnedToolNames: ReadonlySet<string> | undefined;
2075
+ #runtimeSelectedToolNames: ReadonlySet<string> | undefined;
1935
2076
  #baseSystemPrompt: string[];
1936
2077
  #baseSystemPromptBeforeMemoryPromotion: string[] | undefined;
1937
2078
  /**
@@ -2422,12 +2563,17 @@ export class AgentSession {
2422
2563
  readPlan: url => this.#readPlanYoloFile(url),
2423
2564
  listPlanFiles: () => this.#listPlanYoloFiles(),
2424
2565
  });
2566
+ this.setPlanModeState(undefined);
2425
2567
  const previousTools = this.#planYoloPreviousTools;
2426
- if (previousTools) {
2427
- await this.setActiveToolsByName(previousTools);
2568
+ try {
2569
+ if (previousTools) {
2570
+ await this.setActiveToolsByName(previousTools);
2571
+ }
2572
+ } catch (error) {
2573
+ this.setPlanModeState(state);
2574
+ throw error;
2428
2575
  }
2429
2576
  this.setPlanProposalHandler(null);
2430
- this.setPlanModeState(undefined);
2431
2577
  this.#planYolo = undefined;
2432
2578
  this.#planYoloPreviousTools = undefined;
2433
2579
  await this.setModelTemporary(planYolo.target, planYolo.thinkingLevel, { ephemeral: true });
@@ -2485,6 +2631,11 @@ export class AgentSession {
2485
2631
  constructor(config: AgentSessionConfig) {
2486
2632
  this.agent = config.agent;
2487
2633
  this.sessionManager = config.sessionManager;
2634
+ this.#bashSessionTarget = {
2635
+ sessionId: this.sessionManager.getSessionId(),
2636
+ refs: 0,
2637
+ destination: { kind: "current", manager: this.sessionManager },
2638
+ };
2488
2639
  this.settings = config.settings;
2489
2640
  this.#autoApprove = config.autoApprove === true;
2490
2641
  // Power assertions are taken per turn (see #beginInFlight); nothing acquired here.
@@ -2516,6 +2667,7 @@ export class AgentSession {
2516
2667
  this.#skills = config.skills ?? [];
2517
2668
  this.#skillWarnings = config.skillWarnings ?? [];
2518
2669
  this.#customCommands = config.customCommands ?? [];
2670
+ this.#skillsReloadable = config.skillsReloadable ?? true;
2519
2671
  this.#skillsSettings = config.skillsSettings;
2520
2672
  this.#modelRegistry = config.modelRegistry;
2521
2673
  // Resolve the wire service-tier per request so the Fireworks Priority
@@ -2534,7 +2686,8 @@ export class AgentSession {
2534
2686
  this.#toolRegistry = config.toolRegistry ?? new Map();
2535
2687
  this.#createVibeTools = config.createVibeTools;
2536
2688
  this.#builtInToolNames = new Set(config.builtInToolNames ?? []);
2537
- this.#requestedToolNames = config.requestedToolNames;
2689
+ this.#presentationPinnedToolNames = config.presentationPinnedToolNames;
2690
+ this.#ensureWriteRegistered = config.ensureWriteRegistered;
2538
2691
  this.#transformContext = config.transformContext ?? (messages => messages);
2539
2692
  this.#transformProviderContext = config.transformProviderContext;
2540
2693
  this.#sideStreamFn = config.sideStreamFn ?? streamSimple;
@@ -2586,7 +2739,18 @@ export class AgentSession {
2586
2739
  this.#advisorPrimaryTurnsCompleted++;
2587
2740
  if (this.#advisors.length > 0) {
2588
2741
  for (const a of this.#advisors) {
2589
- if (!a.runtime.disposed) a.runtime.onTurnEnd(messages, { willContinue: context?.willContinue });
2742
+ if (a.runtime.disposed) continue;
2743
+ try {
2744
+ a.runtime.onTurnEnd(messages, { willContinue: context?.willContinue });
2745
+ } catch (advisorErr) {
2746
+ // CRITICAL boundary: NOTHING an advisor does may abort the
2747
+ // primary agent's turn-end. A throwing advisor loses its
2748
+ // delta; the primary continues untouched.
2749
+ logger.warn("advisor onTurnEnd threw; delta dropped", {
2750
+ advisor: a.name,
2751
+ err: String(advisorErr),
2752
+ });
2753
+ }
2590
2754
  }
2591
2755
  const syncBacklog = this.settings.get("advisor.syncBacklog");
2592
2756
  if (syncBacklog !== "off") {
@@ -2795,6 +2959,12 @@ export class AgentSession {
2795
2959
  slug = candidate;
2796
2960
  usedSlugs.add(slug);
2797
2961
  }
2962
+ // Per-advisor toggle: skip disabled advisors but keep them in the
2963
+ // status map so they show `○` rather than disappearing.
2964
+ if (config.enabled === false) {
2965
+ this.#advisorStatuses.set(slug, { name: config.name, status: "paused" });
2966
+ continue;
2967
+ }
2798
2968
 
2799
2969
  // Resolve the advisor's model: an explicit `model` override wins; else the
2800
2970
  // `advisor` role chain. A model that fails to resolve skips just this advisor.
@@ -2805,6 +2975,7 @@ export class AgentSession {
2805
2975
  model = resolved.model;
2806
2976
  thinkingLevel = concreteThinkingLevel(resolved.thinkingLevel);
2807
2977
  if (!model) {
2978
+ this.#advisorStatuses.set(slug, { name: config.name, status: "no_model" });
2808
2979
  if (emitWarnings) {
2809
2980
  this.emitNotice("warning", `Advisor "${config.name}": no model matched "${config.model}"`, "advisor");
2810
2981
  }
@@ -2813,6 +2984,7 @@ export class AgentSession {
2813
2984
  } else {
2814
2985
  const sel = resolveAdvisorRoleSelection(this.settings, this.#modelRegistry.getAvailable());
2815
2986
  if (!sel) {
2987
+ this.#advisorStatuses.set(slug, { name: config.name, status: "no_model" });
2816
2988
  if (emitWarnings) {
2817
2989
  logger.debug("advisor enabled but no model assigned to the 'advisor' role; advisor inactive", {
2818
2990
  advisor: config.name,
@@ -2836,6 +3008,11 @@ export class AgentSession {
2836
3008
  const requestedLevel = thinkingLevel ?? ThinkingLevel.Medium;
2837
3009
  const resolvedLevel = resolveThinkingLevelForModel(model, requestedLevel);
2838
3010
  const advisorThinkingLevel: ThinkingLevel = resolvedLevel ?? ThinkingLevel.Inherit;
3011
+ // Record the status entry now (in roster order) so the Map's insertion
3012
+ // order matches the configured roster even when earlier advisors were
3013
+ // skipped as paused/no_model. The build loop overwrites this to "running"
3014
+ // without changing insertion order.
3015
+ this.#advisorStatuses.set(slug, { name: config.name, status: "running" });
2839
3016
  descriptors.push({
2840
3017
  config,
2841
3018
  name: config.name,
@@ -2871,6 +3048,11 @@ export class AgentSession {
2871
3048
  if (!this.#advisorEnabled) return false;
2872
3049
  if (this.#agentKind !== "main" && !this.settings.get("advisor.subagents")) return false;
2873
3050
 
3051
+ // Rebuild the status map from scratch so removed/renamed advisors don't
3052
+ // leave stale entries. #resolveAdvisorRuntimeDescriptors populates every
3053
+ // entry (`paused`/`no_model`/`running`) in roster order; the build loop
3054
+ // below confirms `running` for successfully built advisors.
3055
+ this.#advisorStatuses.clear();
2874
3056
  const descriptors = this.#resolveAdvisorRuntimeDescriptors(true);
2875
3057
 
2876
3058
  // Advisor service tier (`tier.advisor`): "none" (default) runs the advisor
@@ -2911,11 +3093,16 @@ export class AgentSession {
2911
3093
 
2912
3094
  const names = config.tools === undefined ? ADVISOR_DEFAULT_TOOL_NAMES : new Set(config.tools);
2913
3095
  const tools = (this.#advisorTools ?? []).filter(t => names.has(t.name));
3096
+ const advisorLoopTools: AgentTool<any>[] = [adviseTool, ...tools];
3097
+ const advisorToolMap = new Map<string, AgentTool>();
2914
3098
  const availableAdvisorToolNames = new Set<string>();
2915
- availableAdvisorToolNames.add(adviseTool.name);
2916
- for (const tool of tools) {
3099
+ for (const tool of advisorLoopTools) {
2917
3100
  availableAdvisorToolNames.add(tool.name);
2918
- if (tool.customWireName !== undefined) availableAdvisorToolNames.add(tool.customWireName);
3101
+ advisorToolMap.set(tool.name, tool);
3102
+ if (tool.customWireName !== undefined) {
3103
+ availableAdvisorToolNames.add(tool.customWireName);
3104
+ advisorToolMap.set(tool.customWireName, tool);
3105
+ }
2919
3106
  }
2920
3107
  let quarantinedAdvisorOutput: string | undefined;
2921
3108
  let currentAdvisorInput = "";
@@ -2955,17 +3142,36 @@ export class AgentSession {
2955
3142
  // Codex request identity remains UUID-shaped while local labels keep the
2956
3143
  // `-advisor` suffix.
2957
3144
  const advisorPromptCacheKey = this.agent.promptCacheKey ?? advisorProviderSessionId;
3145
+ // On the Cursor provider every tool runs server-side and is dispatched
3146
+ // back through `cursorExecHandlers`; without this bridge the advisor's
3147
+ // own tools (including the MCP `advise` tool) return `toolNotFound` and
3148
+ // no advice is ever routed (issue #5680). Mirrors the primary agent's
3149
+ // bridge (`sdk.ts`), scoped to this advisor's granted tool set.
3150
+ // Cursor's native `delete` frame removes files directly, bypassing the
3151
+ // tool map, so gate it on the advisor actually holding a file-mutating
3152
+ // tool. A default read-only advisor (advise/read/grep/glob) never gets
3153
+ // to delete workspace files it was never granted (issue #5680 review).
3154
+ const advisorCanMutateFiles = advisorToolMap.has("write") || advisorToolMap.has("edit");
3155
+ if (advisorCanMutateFiles) availableAdvisorToolNames.add("delete");
3156
+ const advisorCursorExecHandlers = new CursorExecHandlers({
3157
+ cwd: this.sessionManager.getCwd(),
3158
+ getCwd: () => this.sessionManager.getCwd(),
3159
+ tools: advisorToolMap,
3160
+ allowNativeDelete: advisorCanMutateFiles,
3161
+ });
2958
3162
  const advisorAgent = new Agent({
2959
3163
  initialState: {
2960
3164
  systemPrompt,
2961
3165
  model: advisorModel,
2962
3166
  thinkingLevel: toReasoningEffort(advisorThinkingLevel),
2963
- tools: [adviseTool, ...tools],
3167
+ tools: advisorLoopTools,
2964
3168
  },
2965
3169
  appendOnlyContext,
2966
3170
  sessionId: advisorProviderSessionId,
2967
3171
  promptCacheKey: advisorPromptCacheKey,
2968
3172
  providerSessionState: this.#providerSessionState,
3173
+ cursorExecHandlers: advisorCursorExecHandlers,
3174
+ cwdResolver: () => this.sessionManager.getCwd(),
2969
3175
  preferWebsockets: this.#preferWebsockets,
2970
3176
  getApiKey: requestModel => this.#modelRegistry.resolver(requestModel, advisorProviderSessionId),
2971
3177
  streamFn: this.#advisorStreamFn,
@@ -3036,33 +3242,30 @@ export class AgentSession {
3036
3242
  maintainContext: incomingTokens => this.#maintainAdvisorContext(advisorRef, incomingTokens),
3037
3243
  obfuscator: this.#obfuscator,
3038
3244
  beginAdvisorUpdate: () => advisorRef.emissionGuard.beginUpdate(),
3039
- onTurnError: async error => {
3040
- // Mirror the auth-gateway's usage-limit remedy: the in-stream a/b/c
3041
- // auth retry rotates through siblings within one request but never
3042
- // blocks the LAST failing credential, so without this the advisor
3043
- // re-picks the same exhausted account every retry. Usage limits
3044
- // only — other failures keep the plain retry/notify path (never
3045
- // suspect-mark a credential on a transient advisor error).
3046
- const message = error instanceof Error ? error.message : String(error);
3047
- if (!isUsageLimitOutcome(extractHttpStatusFromError(error), message)) return;
3048
- await this.#modelRegistry.authStorage.markUsageLimitReached(
3049
- advisorModel.provider,
3050
- advisorProviderSessionId,
3051
- {
3052
- retryAfterMs: extractRetryHint(undefined, message),
3053
- baseUrl: advisorModel.baseUrl,
3054
- modelId: advisorModel.id,
3055
- },
3056
- );
3245
+ onTurnError: (error, failedMessages) => this.#recoverAdvisorTurn(advisorRef, error, failedMessages),
3246
+ onTurnSuccess: async () => {
3247
+ const fallback = advisorRef.retryFallback;
3248
+ if (!advisorRef.retryFallbackPendingSuccess || !fallback) return;
3249
+ advisorRef.retryFallbackPendingSuccess = false;
3250
+ await this.#emitSessionEvent({
3251
+ type: "retry_fallback_succeeded",
3252
+ model: formatRetryFallbackSelector(advisorRef.agent.state.model, advisorRef.thinkingLevel),
3253
+ role: fallback.role,
3254
+ });
3057
3255
  },
3058
3256
  notifyFailure: error => {
3257
+ this.#advisorStatuses.set(slug, { name: advisorName, status: "error" });
3059
3258
  const message = error instanceof Error ? error.message : String(error);
3060
3259
  this.emitNotice(
3061
3260
  "warning",
3062
- `Advisor${slug ? ` "${advisorName}"` : ""} unavailable for ${formatModelString(advisorModel)}: ${message}`,
3261
+ `Advisor${slug ? ` "${advisorName}"` : ""} unavailable for ${formatModelString(advisorAgent.state.model)}: ${message}`,
3063
3262
  "advisor",
3064
3263
  );
3065
3264
  },
3265
+ notifyQuotaExhausted: () => {
3266
+ this.#advisorStatuses.set(slug, { name: advisorName, status: "quota_exhausted" });
3267
+ this.emitNotice("warning", `Advisor "${advisorName}" quota exhausted — pausing until reset.`, "advisor");
3268
+ },
3066
3269
  });
3067
3270
 
3068
3271
  const advisorRef: ActiveAdvisor = {
@@ -3076,10 +3279,13 @@ export class AgentSession {
3076
3279
  recorderClosed: Promise.resolve(),
3077
3280
  model: advisorModel,
3078
3281
  thinkingLevel: advisorThinkingLevel,
3282
+ providerSessionId: advisorProviderSessionId,
3283
+ retryFallbackPendingSuccess: false,
3079
3284
  signature,
3080
3285
  };
3081
3286
  this.#attachAdvisorRecorderFeed(advisorRef);
3082
3287
  if (seedToCurrent) runtime.seedTo(this.agent.state.messages.length);
3288
+ this.#advisorStatuses.set(slug, { name: advisorName, status: "running" });
3083
3289
  this.#advisors.push(advisorRef);
3084
3290
  }
3085
3291
 
@@ -3110,11 +3316,11 @@ export class AgentSession {
3110
3316
  /**
3111
3317
  * Route one accepted advice note from `advisor` to the primary. Concern and
3112
3318
  * blocker interrupt the running agent through the steering channel; once the
3113
- * loop has yielded, `triggerTurn` resumes it. If the loop already ended with a
3114
- * terminal text answer and no queued work remains, the note is preserved as an
3115
- * advisor card instead of waking a duplicate completion turn. After a deliberate
3116
- * user interrupt auto-resume is suppressed while idle/unwinding (the note
3117
- * becomes a preserved card re-entering on resume); a live-streaming turn is
3319
+ * loop has yielded, `triggerTurn` resumes it. After a terminal text answer with
3320
+ * no queued work, a concern is preserved as a visible advisor card, while a
3321
+ * blocker wakes the primary to acknowledge work it handed off incorrectly.
3322
+ * After a deliberate user interrupt auto-resume is suppressed while idle/unwinding
3323
+ * (the note becomes a preserved card re-entering on resume); a live-streaming turn is
3118
3324
  * steered in directly. A plain nit always rides the non-interrupting YieldQueue
3119
3325
  * aside. Suppression by the per-advisor emission guard drops the note silently —
3120
3326
  * the model still saw `Recorded.`, so it isn't tempted to rephrase the same note
@@ -3171,10 +3377,19 @@ export class AgentSession {
3171
3377
  });
3172
3378
  return;
3173
3379
  }
3174
- this.#recordAdvisorInterruptDelivered();
3175
- if (this.#planModeState?.enabled) {
3176
- // Plan mode: record advice visibly in context but never wake an
3177
- // autonomous turn only user-driven turns converge on ask/resolve.
3380
+ // A steered interrupting note only continues the run when the session can
3381
+ // actually start (or is already running) a turn. Two idle cases cannot, so
3382
+ // `sendCustomMessage({ triggerTurn: true })` would silently bury the card in
3383
+ // `#pendingNextTurnMessages` until the next user prompt strictly worse than
3384
+ // the visible preserved card. Preserve instead:
3385
+ // - Plan mode: only user-driven turns converge on ask/resolve.
3386
+ // - ACP bridges with `deferAgentInitiatedTurns`: the client cannot show an
3387
+ // agent-initiated turn as busy, so idle triggers are refused (#5628 review).
3388
+ const cannotAutoTrigger =
3389
+ !this.agent.state.isStreaming &&
3390
+ this.#clientBridge?.deferAgentInitiatedTurns === true &&
3391
+ !this.#allowAcpAgentInitiatedTurns;
3392
+ if (this.#planModeState?.enabled || cannotAutoTrigger) {
3178
3393
  this.#preserveAdvisorCard({
3179
3394
  role: "custom",
3180
3395
  customType: "advisor",
@@ -3186,6 +3401,11 @@ export class AgentSession {
3186
3401
  });
3187
3402
  return;
3188
3403
  }
3404
+ // Arm the post-interrupt immune window only now that a turn is actually
3405
+ // being steered/triggered. A merely preserved card never interrupts, so
3406
+ // arming earlier would downgrade the next `advisor.immuneTurns` worth of
3407
+ // real concerns/blockers to skip-idle-flush asides (#5628 review).
3408
+ this.#recordAdvisorInterruptDelivered();
3189
3409
  void this.sendCustomMessage(
3190
3410
  { customType: "advisor", content, display: true, attribution: "agent", details },
3191
3411
  { deliverAs: "steer", triggerTurn: true },
@@ -3227,6 +3447,163 @@ export class AgentSession {
3227
3447
  });
3228
3448
  }
3229
3449
 
3450
+ /** Switch one advisor model while preserving its context and effort invariants. */
3451
+ #setAdvisorModel(advisor: ActiveAdvisor, model: Model, requestedThinkingLevel: ThinkingLevel): ThinkingLevel {
3452
+ const resolvedThinkingLevel = resolveThinkingLevelForModel(model, requestedThinkingLevel);
3453
+ const nextThinkingLevel = resolvedThinkingLevel ?? ThinkingLevel.Inherit;
3454
+ advisor.agent.setModel(model);
3455
+ advisor.agent.setThinkingLevel(toReasoningEffort(nextThinkingLevel));
3456
+ advisor.agent.setDisableReasoning(shouldDisableReasoning(nextThinkingLevel));
3457
+ advisor.agent.appendOnlyContext?.invalidateForModelChange();
3458
+ advisor.model = model;
3459
+ advisor.thinkingLevel = nextThinkingLevel;
3460
+ return nextThinkingLevel;
3461
+ }
3462
+
3463
+ /** Restore an advisor's configured primary once its fallback cooldown expires. */
3464
+ async #maybeRestoreAdvisorRetryFallbackPrimary(advisor: ActiveAdvisor): Promise<void> {
3465
+ const fallback = advisor.retryFallback;
3466
+ if (!fallback || this.#getRetryFallbackRevertPolicy() !== "cooldown-expiry") return;
3467
+
3468
+ const originalSelector = parseRetryFallbackSelector(fallback.originalSelector, this.#modelRegistry);
3469
+ if (!originalSelector) {
3470
+ advisor.retryFallback = undefined;
3471
+ advisor.retryFallbackPendingSuccess = false;
3472
+ return;
3473
+ }
3474
+ const currentSelector = formatRetryFallbackSelector(advisor.agent.state.model, advisor.thinkingLevel);
3475
+ if (currentSelector === originalSelector.raw) {
3476
+ if (!this.#isRetryFallbackSelectorSuppressed(originalSelector)) {
3477
+ advisor.retryFallback = undefined;
3478
+ advisor.retryFallbackPendingSuccess = false;
3479
+ }
3480
+ return;
3481
+ }
3482
+ if (this.#isRetryFallbackSelectorSuppressed(originalSelector)) return;
3483
+
3484
+ const resolvedPrimary = resolveModelOverride([originalSelector.raw], this.#modelRegistry, this.settings);
3485
+ const primaryModel =
3486
+ resolvedPrimary.model ?? this.#modelRegistry.find(originalSelector.provider, originalSelector.id);
3487
+ if (!primaryModel) return;
3488
+ const apiKey = await this.#modelRegistry.getApiKey(primaryModel, advisor.providerSessionId);
3489
+ if (!apiKey) return;
3490
+
3491
+ const thinkingToApply =
3492
+ advisor.thinkingLevel === fallback.lastAppliedThinkingLevel
3493
+ ? fallback.originalThinkingLevel
3494
+ : advisor.thinkingLevel;
3495
+ this.#setAdvisorModel(advisor, primaryModel, thinkingToApply);
3496
+ this.settings.getStorage()?.recordModelUsage(formatModelStringWithRouting(primaryModel));
3497
+ advisor.retryFallback = undefined;
3498
+ advisor.retryFallbackPendingSuccess = false;
3499
+ }
3500
+
3501
+ /**
3502
+ * Apply the advisor's configured provider-failure fallback chain after
3503
+ * same-provider credential rotation has no usable sibling.
3504
+ */
3505
+ async #recoverAdvisorTurn(
3506
+ advisor: ActiveAdvisor,
3507
+ error: unknown,
3508
+ failedMessages: readonly AgentMessage[],
3509
+ ): Promise<boolean> {
3510
+ if (error instanceof AdvisorOutputQuarantinedError) return false;
3511
+
3512
+ const failedMessage = failedMessages.findLast(
3513
+ (message): message is AssistantMessage => message.role === "assistant",
3514
+ );
3515
+ if (failedMessage?.stopReason !== "error") {
3516
+ // Stream setup can reject before any assistant turn is recorded (e.g.
3517
+ // an HTTP 429 thrown from prompt()); classify the raw error so a
3518
+ // structural usage limit still marks the exhausted credential.
3519
+ const message = error instanceof Error ? error.message : String(error);
3520
+ if (!AIError.isUsageLimit(error) && !isUsageLimitOutcome(extractHttpStatusFromError(error), message)) {
3521
+ return false;
3522
+ }
3523
+ const currentModel = advisor.agent.state.model;
3524
+ const outcome = await this.#modelRegistry.authStorage.markUsageLimitReached(
3525
+ currentModel.provider,
3526
+ advisor.providerSessionId,
3527
+ {
3528
+ retryAfterMs: extractRetryHint(undefined, message),
3529
+ baseUrl: currentModel.baseUrl,
3530
+ modelId: currentModel.id,
3531
+ },
3532
+ );
3533
+ return outcome.switched;
3534
+ }
3535
+ if (failedMessage.content.some(block => block.type === "toolCall")) return false;
3536
+
3537
+ const currentModel = advisor.agent.state.model;
3538
+ const message = failedMessage.errorMessage ?? (error instanceof Error ? error.message : String(error));
3539
+ const errorId = AIError.classifyMessage({
3540
+ api: currentModel.api,
3541
+ errorId: failedMessage.errorId,
3542
+ errorMessage: message,
3543
+ errorStatus: failedMessage.errorStatus,
3544
+ });
3545
+ if (AIError.is(errorId, AIError.Flag.Abort) || AIError.is(errorId, AIError.Flag.UserInterrupt)) return false;
3546
+ if (AIError.isContextOverflow(failedMessage, currentModel.contextWindow ?? 0)) return false;
3547
+
3548
+ const currentSelector = formatRetryFallbackSelector(currentModel, advisor.thinkingLevel);
3549
+
3550
+ const retryAfterMs = extractRetryHint(undefined, message);
3551
+ if (
3552
+ AIError.is(errorId, AIError.Flag.UsageLimit) ||
3553
+ isUsageLimitOutcome(extractHttpStatusFromError(error), message)
3554
+ ) {
3555
+ const outcome = await this.#modelRegistry.authStorage.markUsageLimitReached(
3556
+ currentModel.provider,
3557
+ advisor.providerSessionId,
3558
+ {
3559
+ retryAfterMs,
3560
+ baseUrl: currentModel.baseUrl,
3561
+ modelId: currentModel.id,
3562
+ },
3563
+ );
3564
+ if (outcome.switched) return true;
3565
+ }
3566
+
3567
+ const retrySettings = this.settings.getGroup("retry");
3568
+ if (!retrySettings.enabled || !retrySettings.modelFallback) return false;
3569
+ const role = advisor.retryFallback?.role ?? this.#resolveRetryFallbackRole(currentSelector, currentModel);
3570
+ if (!role || this.#findRetryFallbackCandidates(role, currentSelector, currentModel).length === 0) return false;
3571
+
3572
+ this.#noteRetryFallbackCooldown(currentSelector, retryAfterMs, message);
3573
+ for (const selector of this.#findRetryFallbackCandidates(role, currentSelector, currentModel)) {
3574
+ if (this.#isRetryFallbackSelectorSuppressed(selector)) continue;
3575
+ const resolved = resolveModelOverride([selector.raw], this.#modelRegistry, this.settings);
3576
+ const candidate = resolved.model ?? this.#modelRegistry.find(selector.provider, selector.id);
3577
+ if (!candidate || modelsAreEqual(candidate, currentModel)) continue;
3578
+ const apiKey = await this.#modelRegistry.getApiKey(candidate, advisor.providerSessionId);
3579
+ if (!apiKey) continue;
3580
+
3581
+ const originalThinkingLevel = advisor.thinkingLevel;
3582
+ const requestedThinkingLevel = selector.thinkingLevel ?? originalThinkingLevel;
3583
+ const nextThinkingLevel = this.#setAdvisorModel(advisor, candidate, requestedThinkingLevel);
3584
+ if (advisor.retryFallback) {
3585
+ advisor.retryFallback.lastAppliedThinkingLevel = nextThinkingLevel;
3586
+ } else {
3587
+ advisor.retryFallback = {
3588
+ role,
3589
+ originalSelector: currentSelector,
3590
+ originalThinkingLevel,
3591
+ lastAppliedThinkingLevel: nextThinkingLevel,
3592
+ };
3593
+ }
3594
+ advisor.retryFallbackPendingSuccess = true;
3595
+ this.settings.getStorage()?.recordModelUsage(formatModelStringWithRouting(candidate));
3596
+ await this.#emitSessionEvent({
3597
+ type: "retry_fallback_applied",
3598
+ from: currentSelector,
3599
+ to: selector.raw,
3600
+ role,
3601
+ });
3602
+ return true;
3603
+ }
3604
+ return false;
3605
+ }
3606
+
3230
3607
  async #promoteAdvisorContextModel(advisor: ActiveAdvisor, currentModel: Model): Promise<boolean> {
3231
3608
  const promotionSettings = this.settings.getGroup("contextPromotion");
3232
3609
  if (!promotionSettings.enabled) return false;
@@ -3239,10 +3616,7 @@ export class AgentSession {
3239
3616
  // keeps its suffix across a promotion); only the model changes.
3240
3617
  const advisorThinkingLevel = advisor.thinkingLevel;
3241
3618
  try {
3242
- advisor.agent.setModel(targetModel);
3243
- advisor.agent.setThinkingLevel(toReasoningEffort(advisorThinkingLevel));
3244
- advisor.agent.setDisableReasoning(shouldDisableReasoning(advisorThinkingLevel));
3245
- advisor.agent.appendOnlyContext?.invalidateForModelChange();
3619
+ this.#setAdvisorModel(advisor, targetModel, advisorThinkingLevel);
3246
3620
  logger.debug("Advisor context promotion switched model on overflow", {
3247
3621
  advisor: advisor.name,
3248
3622
  from: `${currentModel.provider}/${currentModel.id}`,
@@ -3261,6 +3635,7 @@ export class AgentSession {
3261
3635
  }
3262
3636
 
3263
3637
  async #maintainAdvisorContext(advisor: ActiveAdvisor, incomingTokens: number): Promise<boolean> {
3638
+ await this.#maybeRestoreAdvisorRetryFallbackPrimary(advisor);
3264
3639
  const agent = advisor.agent;
3265
3640
 
3266
3641
  const compactionSettings = this.settings.getGroup("compaction");
@@ -3272,10 +3647,23 @@ export class AgentSession {
3272
3647
  if (contextWindow <= 0) return false;
3273
3648
 
3274
3649
  const messages = agent.state.messages;
3275
- let contextTokens = incomingTokens;
3650
+ const estimateOptions = { excludeEncryptedReasoning: true } as const;
3651
+ let storedConversationTokens = 0;
3276
3652
  for (const message of messages) {
3277
- contextTokens += estimateTokens(message);
3278
- }
3653
+ storedConversationTokens += estimateTokens(message, estimateOptions);
3654
+ }
3655
+ // Provider usage (including cache reads and generated output) is the
3656
+ // trustworthy anchor for accumulated context. Add only the trailing incoming
3657
+ // delta to that arm. Floor it by a full local estimate — fixed advisor system
3658
+ // prompt, tool schemas, stored messages, and incoming delta — so provider
3659
+ // under-reporting or payload transforms cannot suppress maintenance.
3660
+ const providerContextTokens = this.#estimateAdvisorContextTokens(messages) + incomingTokens;
3661
+ const localContextTokens =
3662
+ countTokens(agent.state.systemPrompt) +
3663
+ estimateToolSchemaTokens(agent.state.tools) +
3664
+ storedConversationTokens +
3665
+ incomingTokens;
3666
+ const contextTokens = compactionContextTokens(providerContextTokens, localContextTokens);
3279
3667
 
3280
3668
  if (!shouldCompact(contextTokens, contextWindow, compactionSettings)) {
3281
3669
  return false;
@@ -3299,6 +3687,7 @@ export class AgentSession {
3299
3687
  const timestamp = String(message.timestamp || Date.now());
3300
3688
 
3301
3689
  if (message.role === "compactionSummary") {
3690
+ const advisorSummary = message as AdvisorCompactionSummaryMessage;
3302
3691
  return {
3303
3692
  type: "compaction",
3304
3693
  id,
@@ -3306,9 +3695,7 @@ export class AgentSession {
3306
3695
  timestamp,
3307
3696
  summary: message.summary,
3308
3697
  shortSummary: message.shortSummary,
3309
- firstKeptEntryId:
3310
- (message as CompactionSummaryMessage & { firstKeptEntryId?: string }).firstKeptEntryId ||
3311
- `msg-${i + 1}`,
3698
+ firstKeptEntryId: advisorSummary.firstKeptEntryId || `msg-${i + 1}`,
3312
3699
  tokensBefore: message.tokensBefore,
3313
3700
  } satisfies CompactionEntry;
3314
3701
  }
@@ -3402,11 +3789,15 @@ export class AgentSession {
3402
3789
  const firstKeptEntryId = compactResult.firstKeptEntryId;
3403
3790
  const tokensBefore = compactResult.tokensBefore;
3404
3791
 
3405
- // Rebuild messages with the compaction summary
3792
+ // The retained messages still carry provider usage from before this
3793
+ // compaction. Record their exact array boundary on the in-memory summary so
3794
+ // only assistants appended afterward can become the next usage anchor.
3795
+ const advisorUsageAnchorStartIndex = preparation.recentMessages.length + 1;
3406
3796
  const summaryMessage = {
3407
3797
  ...createCompactionSummaryMessage(summary, tokensBefore, new Date().toISOString(), shortSummary),
3408
3798
  firstKeptEntryId,
3409
- } as CompactionSummaryMessage & { firstKeptEntryId?: string };
3799
+ advisorUsageAnchorStartIndex,
3800
+ } satisfies AdvisorCompactionSummaryMessage;
3410
3801
 
3411
3802
  agent.replaceMessages([summaryMessage, ...preparation.recentMessages]);
3412
3803
  return false;
@@ -3962,7 +4353,7 @@ export class AgentSession {
3962
4353
  }
3963
4354
  const skipPersistedRewindResult =
3964
4355
  message.role === "toolResult" &&
3965
- message.toolName === "rewind" &&
4356
+ semanticToolResult(message.toolName, message)?.toolName === "rewind" &&
3966
4357
  this.#rewoundToolResultIds.delete(message.toolCallId);
3967
4358
  if (!skipPersistedRewindResult) {
3968
4359
  this.#appendSessionMessage(message);
@@ -4333,29 +4724,28 @@ export class AgentSession {
4333
4724
  }
4334
4725
  }
4335
4726
  if (event.message.role === "toolResult") {
4336
- const { toolName, toolCallId, details, isError, content } = event.message as {
4337
- toolCallId?: string;
4338
- toolName?: string;
4339
- details?: { op?: string; path?: string; phases?: TodoPhase[]; report?: string; startedAt?: string };
4340
- isError?: boolean;
4341
- content?: Array<TextContent | ImageContent>;
4342
- };
4727
+ const { toolName, toolCallId, isError, content } = event.message;
4728
+ const details = isRecord(event.message.details) ? event.message.details : undefined;
4729
+ const semanticResult = semanticToolResult(toolName, event.message);
4730
+ const semanticDetails = isRecord(semanticResult?.details) ? semanticResult.details : undefined;
4343
4731
  // A tool actually ran. Clear the post-reminder suppression: the agent did
4344
4732
  // productive work in response to the prior nudge, so the next text-only stop
4345
4733
  // is allowed to escalate to the next reminder if todos remain incomplete.
4346
4734
  this.#todoReminderAwaitingProgress = false;
4347
4735
  // Invalidate streaming edit cache when edit tool completes to prevent stale data
4348
- if (toolName === "edit" && details?.path) {
4349
- this.#invalidateFileCacheForPath(details.path);
4736
+ const editedPath = details ? getStringProperty(details, "path") : undefined;
4737
+ if (toolName === "edit" && editedPath) {
4738
+ this.#invalidateFileCacheForPath(editedPath);
4350
4739
  }
4351
- if (toolName === "todo" && !isError && Array.isArray(details?.phases)) {
4352
- this.setTodoPhases(details.phases);
4740
+ const phases = details?.phases;
4741
+ if (toolName === "todo" && !isError && details && Array.isArray(phases) && phases.every(isTodoPhase)) {
4742
+ this.setTodoPhases(phases);
4353
4743
  if (this.#isTodoInitResult(details, toolCallId)) {
4354
4744
  this.#scheduleReplanTitleRefresh();
4355
4745
  }
4356
4746
  }
4357
4747
  if (toolName === "todo" && isError) {
4358
- const errorText = content?.find(part => part.type === "text")?.text;
4748
+ const errorText = content.find(part => part.type === "text")?.text;
4359
4749
  const reminderText = [
4360
4750
  "<system-reminder>",
4361
4751
  "todo failed, so todo progress is not visible to the user.",
@@ -4373,18 +4763,19 @@ export class AgentSession {
4373
4763
  { deliverAs: "nextTurn" },
4374
4764
  );
4375
4765
  }
4376
- if (toolName === "checkpoint" && !isError) {
4766
+ if (semanticResult?.toolName === "checkpoint" && !isError) {
4377
4767
  const checkpointEntryId = this.sessionManager.getEntries().at(-1)?.id ?? null;
4378
4768
  this.#checkpointState = {
4379
4769
  checkpointMessageCount: this.agent.state.messages.length,
4380
4770
  checkpointEntryId,
4381
- startedAt: details?.startedAt ?? new Date().toISOString(),
4771
+ startedAt:
4772
+ (semanticDetails && stringProperty(semanticDetails, "startedAt")) ?? new Date().toISOString(),
4382
4773
  };
4383
4774
  this.#pendingRewindReport = undefined;
4384
4775
  this.#lastCompletedRewind = undefined;
4385
4776
  }
4386
- if (toolName === "rewind" && !isError && this.#checkpointState) {
4387
- const detailReport = typeof details?.report === "string" ? details.report.trim() : "";
4777
+ if (semanticResult?.toolName === "rewind" && !isError && this.#checkpointState) {
4778
+ const detailReport = semanticDetails ? (stringProperty(semanticDetails, "report")?.trim() ?? "") : "";
4388
4779
  const textReport = content?.find(part => part.type === "text")?.text?.trim() ?? "";
4389
4780
  const report = detailReport || textReport;
4390
4781
  if (report.length > 0) {
@@ -4397,8 +4788,11 @@ export class AgentSession {
4397
4788
  // Check auto-retry and auto-compaction after agent completes
4398
4789
  if (event.type === "agent_end") {
4399
4790
  const settledMessages = this.agent.state.messages;
4400
- const emitAgentEndNotification = async () => {
4401
- await this.#emitAgentEndNotification(settledMessages);
4791
+ // TTSR retry work runs concurrently and clears the live flag before
4792
+ // maintenance can emit agent_end, so preserve the state at settle entry.
4793
+ const ttsrAbortPendingAtAgentEnd = this.#ttsrAbortPending;
4794
+ const emitAgentEndNotification = async (options?: { willContinue?: boolean }) => {
4795
+ await this.#emitAgentEndNotification(settledMessages, options);
4402
4796
  };
4403
4797
  const usage = this.getSessionStats().tokens;
4404
4798
  await this.#goalRuntime.onAgentEnd({
@@ -4500,7 +4894,7 @@ export class AgentSession {
4500
4894
  // active-goal threshold pre-empt below.
4501
4895
  if (await this.#handleEmptyAssistantStop(msg)) {
4502
4896
  maintenanceRoute("empty-stop-handled");
4503
- await emitAgentEndNotification();
4897
+ await emitAgentEndNotification({ willContinue: true });
4504
4898
  return;
4505
4899
  }
4506
4900
 
@@ -4523,30 +4917,34 @@ export class AgentSession {
4523
4917
  automaticContinuationBlocked: compactionResult.automaticContinuationBlocked === true,
4524
4918
  });
4525
4919
  this.#resolveRetry();
4526
- await emitAgentEndNotification();
4920
+ await emitAgentEndNotification(
4921
+ compactionResult.continuationScheduled ? { willContinue: true } : undefined,
4922
+ );
4527
4923
  return;
4528
4924
  }
4529
4925
  }
4530
4926
 
4531
4927
  if (await this.#handleUnexpectedAssistantStop(msg)) {
4532
4928
  maintenanceRoute("unexpected-stop-handled");
4533
- await emitAgentEndNotification();
4929
+ await emitAgentEndNotification({ willContinue: true });
4534
4930
  return;
4535
4931
  }
4536
4932
 
4537
4933
  if (this.#isRetryableReasonlessAbort(msg)) {
4538
4934
  const didRetry = await this.#handleRetryableError(msg, { allowModelFallback: false });
4539
4935
  if (didRetry) {
4540
- await emitAgentEndNotification();
4936
+ await emitAgentEndNotification({ willContinue: true });
4541
4937
  return;
4542
4938
  }
4543
4939
  }
4544
4940
 
4545
- // A deliberate abort should settle the current turn, not trigger queued continuations.
4941
+ // A deliberate abort should settle the current turn, not trigger queued
4942
+ // continuations — except TTSR self-repair, which already scheduled a
4943
+ // hidden retry while #ttsrAbortPending is still true.
4546
4944
  if (msg.stopReason === "aborted") {
4547
4945
  this.#resolveRetry();
4548
4946
  this.#resetSessionStopContinuationState();
4549
- await emitAgentEndNotification();
4947
+ await emitAgentEndNotification(ttsrAbortPendingAtAgentEnd ? { willContinue: true } : undefined);
4550
4948
  return;
4551
4949
  }
4552
4950
  // Fireworks Fast variants degrade to their base model on a failed turn —
@@ -4555,14 +4953,14 @@ export class AgentSession {
4555
4953
  if (this.#isFireworksFastFallbackEligible(msg)) {
4556
4954
  const didRetry = await this.#handleRetryableError(msg, { fireworksFastFallback: true });
4557
4955
  if (didRetry) {
4558
- await emitAgentEndNotification();
4956
+ await emitAgentEndNotification({ willContinue: true });
4559
4957
  return;
4560
4958
  }
4561
4959
  }
4562
4960
  if (this.#isRetryableError(msg)) {
4563
4961
  const didRetry = await this.#handleRetryableError(msg);
4564
4962
  if (didRetry) {
4565
- await emitAgentEndNotification();
4963
+ await emitAgentEndNotification({ willContinue: true });
4566
4964
  return;
4567
4965
  }
4568
4966
  } else if (this.#isHardErrorFallbackEligible(msg)) {
@@ -4573,7 +4971,7 @@ export class AgentSession {
4573
4971
  // backoff-retry of the failing model) when no switch happens.
4574
4972
  const didRetry = await this.#handleRetryableError(msg, { hardErrorFallback: true });
4575
4973
  if (didRetry) {
4576
- await emitAgentEndNotification();
4974
+ await emitAgentEndNotification({ willContinue: true });
4577
4975
  return;
4578
4976
  }
4579
4977
  }
@@ -4612,22 +5010,22 @@ export class AgentSession {
4612
5010
  compactionResult.continuationScheduled ||
4613
5011
  compactionResult.automaticContinuationBlocked
4614
5012
  ) {
4615
- await emitAgentEndNotification();
5013
+ await emitAgentEndNotification(compactionResult.continuationScheduled ? { willContinue: true } : undefined);
4616
5014
  return;
4617
5015
  }
4618
5016
  if (msg.stopReason !== "error") {
4619
5017
  if (this.#enforceRewindBeforeYield()) {
4620
- await emitAgentEndNotification();
5018
+ await emitAgentEndNotification({ willContinue: true });
4621
5019
  return;
4622
5020
  }
4623
5021
  const planModeContinuationScheduled = await this.#enforcePlanModeDecisionAtSettle();
4624
5022
  if (planModeContinuationScheduled) {
4625
- await emitAgentEndNotification();
5023
+ await emitAgentEndNotification({ willContinue: true });
4626
5024
  return;
4627
5025
  }
4628
5026
  const todoContinuationScheduled = await this.#checkTodoCompletion(msg);
4629
5027
  if (todoContinuationScheduled) {
4630
- await emitAgentEndNotification();
5028
+ await emitAgentEndNotification({ willContinue: true });
4631
5029
  return;
4632
5030
  }
4633
5031
  }
@@ -4637,11 +5035,11 @@ export class AgentSession {
4637
5035
  // the session is fully idle (the todo reminder above defers the same
4638
5036
  // way inside #checkTodoCompletion).
4639
5037
  if (this.#hasPendingAsyncWake()) {
4640
- await emitAgentEndNotification();
5038
+ await emitAgentEndNotification({ willContinue: true });
4641
5039
  return;
4642
5040
  }
4643
- await this.#emitSessionStopEvent(settledMessages, msg);
4644
- await emitAgentEndNotification();
5041
+ const sessionStopWillContinue = await this.#emitSessionStopEvent(settledMessages, msg);
5042
+ await emitAgentEndNotification(sessionStopWillContinue ? { willContinue: true } : undefined);
4645
5043
  }
4646
5044
  };
4647
5045
 
@@ -4771,7 +5169,28 @@ export class AgentSession {
4771
5169
  );
4772
5170
  }
4773
5171
 
4774
- #scheduleAutoContinuePrompt(generation: number): void {
5172
+ #scheduleCompactionContinuation(options: {
5173
+ generation: number;
5174
+ autoContinue: boolean;
5175
+ terminalTextAnswer: boolean;
5176
+ suppressContinuation: boolean;
5177
+ }): boolean {
5178
+ if (options.suppressContinuation) return false;
5179
+ if (this.agent.hasQueuedMessages()) {
5180
+ this.#scheduleAgentContinue({
5181
+ delayMs: 100,
5182
+ generation: options.generation,
5183
+ shouldContinue: () => this.agent.hasQueuedMessages(),
5184
+ });
5185
+ return true;
5186
+ }
5187
+ if (!options.autoContinue) return false;
5188
+ const activeGoal = this.#goalModeState?.enabled === true && this.#goalModeState.goal.status === "active";
5189
+ if (options.terminalTextAnswer && !activeGoal) return false;
5190
+ return this.#scheduleAutoContinuePrompt(options.generation);
5191
+ }
5192
+
5193
+ #scheduleAutoContinuePrompt(generation: number): boolean {
4775
5194
  const continuePrompt = async () => {
4776
5195
  // Compaction summarizes away the first-message eager preludes, so re-assert the
4777
5196
  // delegate-via-tasks / phased-todo reminders on this auto-resumed turn. This runs
@@ -4796,10 +5215,18 @@ export class AgentSession {
4796
5215
  async signal => {
4797
5216
  await Promise.resolve();
4798
5217
  if (signal.aborted) return;
5218
+ if (this.agent.hasQueuedMessages()) {
5219
+ this.#scheduleAgentContinue({
5220
+ generation,
5221
+ shouldContinue: () => this.agent.hasQueuedMessages(),
5222
+ });
5223
+ return;
5224
+ }
4799
5225
  await continuePrompt();
4800
5226
  },
4801
5227
  { generation },
4802
5228
  );
5229
+ return true;
4803
5230
  }
4804
5231
 
4805
5232
  async #cancelPostPromptTasks(): Promise<void> {
@@ -5585,10 +6012,9 @@ export class AgentSession {
5585
6012
 
5586
6013
  // `local://` URLs (e.g. local://PLAN.md for plan-mode) resolve to a real
5587
6014
  // on-disk artifacts path; pre-caching works as long as we ask the
5588
- // local-protocol handler. Other internal-scheme URLs (agent://, skill://,
5589
- // rule://, mcp://, artifact://) have no stable filesystem representation;
5590
- // skip pre-cache entirely for those the edit tool itself will reject
5591
- // them through its normal dispatch path.
6015
+ // local-protocol handler. Other internal-scheme URLs have no local
6016
+ // filesystem representation; skip pre-cache entirely for those — the
6017
+ // edit tool itself will reject them through its normal dispatch path.
5592
6018
  const resolvedPath = this.#resolveSessionFsPath(path);
5593
6019
  if (resolvedPath === undefined) return undefined;
5594
6020
 
@@ -5688,9 +6114,8 @@ export class AgentSession {
5688
6114
  * - `local://` URLs route through the local-protocol handler so they map
5689
6115
  * onto the session's on-disk artifacts directory; pre-caching, ENOENT
5690
6116
  * handling, and post-edit invalidation all work normally.
5691
- * - Other internal-scheme URLs (agent://, skill://, rule://, mcp://,
5692
- * artifact://) have no stable filesystem path; this returns `undefined`
5693
- * so callers skip filesystem-only operations.
6117
+ * - Other internal-scheme URLs have no local filesystem path; this returns
6118
+ * `undefined` so callers skip filesystem-only operations.
5694
6119
  * - Cwd-relative and absolute paths resolve via `resolveToCwd`.
5695
6120
  */
5696
6121
  #resolveSessionFsPath(filePath: string): string | undefined {
@@ -5698,15 +6123,7 @@ export class AgentSession {
5698
6123
  if (normalized.startsWith("local:")) {
5699
6124
  return resolveLocalUrlToPath(normalized, this.#localProtocolOptions());
5700
6125
  }
5701
- if (
5702
- normalized.startsWith("agent://") ||
5703
- normalized.startsWith("skill://") ||
5704
- normalized.startsWith("rule://") ||
5705
- normalized.startsWith("mcp://") ||
5706
- normalized.startsWith("artifact://")
5707
- ) {
5708
- return undefined;
5709
- }
6126
+ if (isInternalUrlPath(normalized)) return undefined;
5710
6127
  return resolveToCwd(normalized, this.sessionManager.getCwd());
5711
6128
  }
5712
6129
 
@@ -5870,15 +6287,22 @@ export class AgentSession {
5870
6287
  return undefined;
5871
6288
  }
5872
6289
 
5873
- async #emitAgentEndNotification(messages: AgentMessage[]): Promise<void> {
5874
- await this.#extensionRunner?.emit({ type: "agent_end", messages });
6290
+ async #emitAgentEndNotification(messages: AgentMessage[], options?: { willContinue?: boolean }): Promise<void> {
6291
+ await this.#extensionRunner?.emit({
6292
+ type: "agent_end",
6293
+ messages,
6294
+ willContinue: options?.willContinue,
6295
+ });
5875
6296
  }
5876
6297
 
6298
+ /** @returns true when a hidden session_stop continuation turn was scheduled. */
5877
6299
  async #emitSessionStopEvent(
5878
6300
  messages: AgentMessage[],
5879
6301
  lastAssistantMessage = this.getLastAssistantMessage(),
5880
- ): Promise<void> {
5881
- if (this.#agentKind === "sub" || !this.#extensionRunner?.hasHandlers("session_stop")) return;
6302
+ ): Promise<boolean> {
6303
+ if (this.#agentKind === "sub" || !this.#extensionRunner?.hasHandlers("session_stop")) {
6304
+ return false;
6305
+ }
5882
6306
  const generation = this.#promptGeneration;
5883
6307
  const result = await this.#extensionRunner.emitSessionStop({
5884
6308
  messages,
@@ -5890,12 +6314,12 @@ export class AgentSession {
5890
6314
  });
5891
6315
  if (this.#promptGeneration !== generation || this.#abortInProgress || this.#isDisposed) {
5892
6316
  this.#resetSessionStopContinuationState();
5893
- return;
6317
+ return false;
5894
6318
  }
5895
6319
  const additionalContext = this.#sessionStopContinuationContext(result);
5896
6320
  if (!additionalContext) {
5897
6321
  this.#resetSessionStopContinuationState();
5898
- return;
6322
+ return false;
5899
6323
  }
5900
6324
  if (this.#sessionStopContinuationCount >= SESSION_STOP_CONTINUATION_CAP) {
5901
6325
  logger.warn("session_stop continuation cap reached", {
@@ -5903,7 +6327,7 @@ export class AgentSession {
5903
6327
  cap: SESSION_STOP_CONTINUATION_CAP,
5904
6328
  });
5905
6329
  this.#resetSessionStopContinuationState();
5906
- return;
6330
+ return false;
5907
6331
  }
5908
6332
  this.#sessionStopContinuationCount++;
5909
6333
  this.#sessionStopHookActive = true;
@@ -5918,6 +6342,7 @@ export class AgentSession {
5918
6342
  },
5919
6343
  true,
5920
6344
  );
6345
+ return true;
5921
6346
  }
5922
6347
 
5923
6348
  /** Emit extension events based on session events */
@@ -6190,6 +6615,43 @@ export class AgentSession {
6190
6615
  await this.refreshBaseSystemPrompt();
6191
6616
  }
6192
6617
  }
6618
+ /** Run one abortable auto-learn capture outside the primary agent loop. */
6619
+ async runAutolearnCapture(capture: (signal: AbortSignal) => Promise<void>): Promise<void> {
6620
+ if (this.#autolearnCaptureTask || this.#isDisposed) return;
6621
+ const controller = new AbortController();
6622
+ this.#autolearnCaptureAbortController = controller;
6623
+ const task = (async () => {
6624
+ try {
6625
+ await capture(controller.signal);
6626
+ } catch (error) {
6627
+ if (!controller.signal.aborted) throw error;
6628
+ } finally {
6629
+ if (this.#autolearnCaptureAbortController === controller) {
6630
+ this.#autolearnCaptureAbortController = undefined;
6631
+ }
6632
+ }
6633
+ })();
6634
+ this.#autolearnCaptureTask = task;
6635
+ try {
6636
+ await task;
6637
+ } finally {
6638
+ if (this.#autolearnCaptureTask === task) this.#autolearnCaptureTask = undefined;
6639
+ }
6640
+ }
6641
+
6642
+ #abortAutolearnCapture(): void {
6643
+ this.#autolearnCaptureAbortController?.abort();
6644
+ }
6645
+
6646
+ async #drainAutolearnCapture(): Promise<void> {
6647
+ const task = this.#autolearnCaptureTask;
6648
+ if (!task) return;
6649
+ try {
6650
+ await withTimeout(task, 3_000, "Timed out draining auto-learn capture during dispose");
6651
+ } catch (error) {
6652
+ logger.warn("Auto-learn capture did not settle during dispose", { error: String(error) });
6653
+ }
6654
+ }
6193
6655
 
6194
6656
  /** True once dispose() has begun; deferred background work (e.g. the deferred
6195
6657
  * MCP discovery task in sdk.ts) must not touch the session past this point. */
@@ -6212,6 +6674,8 @@ export class AgentSession {
6212
6674
  */
6213
6675
  beginDispose(): void {
6214
6676
  this.#isDisposed = true;
6677
+ this.#titleGenerationAbortController.abort();
6678
+ this.#abortAutolearnCapture();
6215
6679
  this.#flushPendingIrcAsides();
6216
6680
  this.yieldQueue.clear();
6217
6681
  this.agent.setAsideMessageProvider(undefined);
@@ -6248,6 +6712,12 @@ export class AgentSession {
6248
6712
  } catch (error) {
6249
6713
  logger.warn("Failed to emit session_shutdown event", { error: String(error) });
6250
6714
  }
6715
+ // Clear any timers extensions scheduled via `ctx.setInterval`/`ctx.setTimeout`
6716
+ // so their background work does not outlive the session (issue #5664).
6717
+ // Optional-called: hosts and tests may inject partial runner facades that
6718
+ // implement only the dispatch surface.
6719
+ this.#extensionRunner?.clearManagedTimers?.();
6720
+ this.#fallbackExtensionTimers?.clearAll();
6251
6721
  // Abort post-prompt work so the drain below can complete. Without this, a
6252
6722
  // deferred-handoff task that has already advanced into
6253
6723
  // `await this.handoff(...) → generateHandoff(...)` keeps awaiting a live LLM stream
@@ -6265,6 +6735,7 @@ export class AgentSession {
6265
6735
  const postPromptDrain = this.#cancelPostPromptTasks();
6266
6736
  this.agent.abort();
6267
6737
  await postPromptDrain;
6738
+ await this.#drainAutolearnCapture();
6268
6739
  // Cancel jobs this agent registered so a subagent's teardown doesn't
6269
6740
  // leak its background bash/task work into the parent's manager. Only
6270
6741
  // the session that owns the manager goes on to dispose it (which itself
@@ -6739,50 +7210,99 @@ export class AgentSession {
6739
7210
 
6740
7211
  async #applyActiveToolsByName(toolNames: string[]): Promise<void> {
6741
7212
  toolNames = normalizeToolNames(toolNames);
7213
+ const selectedTools = toolNames.flatMap(name => {
7214
+ const tool = this.#toolRegistry.get(name);
7215
+ return tool ? [{ name, tool }] : [];
7216
+ });
7217
+ const xdevReadAvailable = this.#builtInToolNames.has("read") && selectedTools.some(({ name }) => name === "read");
7218
+ const isPresentationPinned = (name: string): boolean =>
7219
+ this.#presentationPinnedToolNames?.has(name) === true || this.#runtimeSelectedToolNames?.has(name) === true;
7220
+ const mountCandidates = selectedTools.filter(
7221
+ ({ name, tool }) =>
7222
+ this.#xdevRegistry !== undefined &&
7223
+ xdevReadAvailable &&
7224
+ !isPresentationPinned(name) &&
7225
+ isMountableUnderXdev(tool),
7226
+ );
7227
+
7228
+ let builtInWriteAvailable = this.#builtInToolNames.has("write");
7229
+ if (mountCandidates.length > 0 && !builtInWriteAvailable) {
7230
+ builtInWriteAvailable = (await this.#ensureWriteRegistered?.()) === true;
7231
+ if (builtInWriteAvailable) this.#builtInToolNames.add("write");
7232
+ }
7233
+ const mountNames = builtInWriteAvailable ? new Set(mountCandidates.map(({ name }) => name)) : new Set<string>();
6742
7234
  const tools: AgentTool[] = [];
6743
7235
  const validToolNames: string[] = [];
6744
7236
  const mountedTools: AgentTool[] = [];
6745
- for (const name of toolNames) {
6746
- const tool = this.#toolRegistry.get(name);
6747
- if (!tool) continue;
6748
- // Discoverable tools are presented as `xd://` devices (kept out of the
6749
- // top-level schema) when the transport is active; everything else stays
6750
- // top-level. `loadMode` decides presentation only — selection is upstream.
6751
- if (this.#xdevRegistry && isMountableUnderXdev(tool)) {
6752
- mountedTools.push(tool);
7237
+ for (const { name, tool } of selectedTools) {
7238
+ if (mountNames.has(name)) {
7239
+ mountedTools.push(this.#wrapToolForAcpPermission(tool));
6753
7240
  } else {
6754
7241
  tools.push(this.#wrapToolForAcpPermission(tool));
6755
7242
  validToolNames.push(name);
6756
7243
  }
6757
7244
  }
6758
- // Reconcile the dynamic `xd://` mounts: newly-active discoverable tools are
6759
- // mounted, deactivated ones dropped (built-in devices are preserved). A
6760
- // removed or disconnected tool must not stay callable through a stale device.
7245
+
7246
+ const pinnedWrite = isPresentationPinned("write");
7247
+ const activeDeferrableTool = tools.some(tool => tool.deferrable === true);
7248
+ const transportNeeded = mountedTools.length > 0 || activeDeferrableTool || this.#planModeState?.enabled === true;
7249
+ if (transportNeeded && !builtInWriteAvailable) {
7250
+ builtInWriteAvailable = (await this.#ensureWriteRegistered?.()) === true;
7251
+ if (builtInWriteAvailable) this.#builtInToolNames.add("write");
7252
+ }
7253
+ if (transportNeeded && builtInWriteAvailable) {
7254
+ const write = this.#toolRegistry.get("write");
7255
+ if (write && !validToolNames.includes("write")) {
7256
+ tools.push(this.#wrapToolForAcpPermission(write));
7257
+ validToolNames.push("write");
7258
+ }
7259
+ } else if (
7260
+ !pinnedWrite &&
7261
+ (this.#presentationPinnedToolNames !== undefined || this.#runtimeSelectedToolNames !== undefined)
7262
+ ) {
7263
+ const writeNameIndex = validToolNames.indexOf("write");
7264
+ if (writeNameIndex >= 0 && this.#builtInToolNames.has("write")) validToolNames.splice(writeNameIndex, 1);
7265
+ const writeToolIndex = tools.findIndex(tool => tool.name === "write" && this.#builtInToolNames.has("write"));
7266
+ if (writeToolIndex >= 0) tools.splice(writeToolIndex, 1);
7267
+ }
7268
+
6761
7269
  const previousMounted = this.#mountedXdevToolNames;
7270
+ const previousMountedTools = [...previousMounted].flatMap(name => {
7271
+ const tool = this.#xdevRegistry?.get(name);
7272
+ return tool ? [tool] : [];
7273
+ });
7274
+ const previousActiveToolNames = this.getActiveToolNames();
6762
7275
  this.#mountedXdevToolNames = new Set(mountedTools.map(tool => tool.name));
6763
7276
  this.#xdevRegistry?.reconcile(mountedTools);
6764
- this.#notifyXdevMountDelta(previousMounted);
6765
7277
  this.#setActiveToolNames?.(validToolNames);
6766
- this.agent.setTools(tools);
6767
7278
 
6768
- // Rebuild base system prompt with new tool set, but only when the tool set
6769
- // actually changed. MCP servers can reconnect at arbitrary times and call
6770
- // `refreshMCPTools` -> `#applyActiveToolsByName` even though the resulting
6771
- // tool list is byte-identical. Skipping the rebuild keeps the system prompt
6772
- // stable, which is required for Anthropic prompt caching to keep hitting.
6773
- if (this.#rebuildSystemPrompt) {
6774
- const signature = this.#computeAppliedToolSignature(validToolNames, tools);
6775
- if (signature !== this.#lastAppliedToolSignature) {
6776
- if (this.#lastAppliedToolSignature !== undefined) {
6777
- this.#clearInheritedProviderPromptCacheKey();
7279
+ let rebuiltSystemPrompt: string[] | undefined;
7280
+ let rebuiltSignature: string | undefined;
7281
+ try {
7282
+ if (this.#rebuildSystemPrompt) {
7283
+ const signature = this.#computeAppliedToolSignature(validToolNames, tools);
7284
+ if (signature !== this.#lastAppliedToolSignature) {
7285
+ const built = await this.#rebuildSystemPrompt(validToolNames, this.#toolRegistry);
7286
+ rebuiltSystemPrompt = built.systemPrompt;
7287
+ rebuiltSignature = signature;
6778
7288
  }
6779
- const built = await this.#rebuildSystemPrompt(validToolNames, this.#toolRegistry);
6780
- this.#baseSystemPrompt = built.systemPrompt;
6781
- this.#baseSystemPromptBeforeMemoryPromotion = undefined;
6782
- this.agent.setSystemPrompt(this.#baseSystemPrompt);
6783
- this.#lastAppliedToolSignature = signature;
6784
- this.#promptModelKey = this.#currentPromptModelKey();
6785
7289
  }
7290
+ } catch (error) {
7291
+ this.#mountedXdevToolNames = previousMounted;
7292
+ this.#xdevRegistry?.reconcile(previousMountedTools);
7293
+ this.#setActiveToolNames?.(previousActiveToolNames);
7294
+ throw error;
7295
+ }
7296
+
7297
+ this.#notifyXdevMountDelta(previousMounted);
7298
+ this.agent.setTools(tools);
7299
+ if (rebuiltSystemPrompt && rebuiltSignature) {
7300
+ if (this.#lastAppliedToolSignature !== undefined) this.#clearInheritedProviderPromptCacheKey();
7301
+ this.#baseSystemPrompt = rebuiltSystemPrompt;
7302
+ this.#baseSystemPromptBeforeMemoryPromotion = undefined;
7303
+ this.agent.setSystemPrompt(this.#baseSystemPrompt);
7304
+ this.#lastAppliedToolSignature = rebuiltSignature;
7305
+ this.#promptModelKey = this.#currentPromptModelKey();
6786
7306
  }
6787
7307
  }
6788
7308
 
@@ -6811,12 +7331,41 @@ export class AgentSession {
6811
7331
  display: false,
6812
7332
  timestamp: Date.now(),
6813
7333
  });
7334
+ if (this.settings.get("startup.quiet")) return;
6814
7335
  const parts: string[] = [];
6815
7336
  if (added.length > 0) parts.push(`mounted ${added.map(entry => entry.name).join(", ")}`);
6816
7337
  if (removed.length > 0) parts.push(`unmounted ${removed.map(entry => entry.name).join(", ")}`);
6817
7338
  this.emitNotice("info", `xd://: ${parts.join("; ")}`, "xdev");
6818
7339
  }
6819
7340
 
7341
+ /**
7342
+ * Rediscover disk-backed skills and rebuild prompt-facing state without
7343
+ * recreating the session. Explicit skill snapshots (`--no-skills`,
7344
+ * SDK-provided `skills`) remain fixed for the lifetime of the session.
7345
+ */
7346
+ async refreshSkills(): Promise<void> {
7347
+ if (!this.#skillsReloadable) {
7348
+ return;
7349
+ }
7350
+
7351
+ resetCapabilities();
7352
+ const skillsSettings = this.settings.getGroup("skills");
7353
+ const discovered = await loadSkills({
7354
+ ...skillsSettings,
7355
+ cwd: this.sessionManager.getCwd(),
7356
+ disabledExtensions: this.settings.get("disabledExtensions") ?? [],
7357
+ });
7358
+ this.#skills = discovered.skills;
7359
+ this.#skillWarnings = discovered.warnings;
7360
+ this.#skillsSettings = skillsSettings;
7361
+
7362
+ if (this.#agentKind === "main") {
7363
+ setActiveSkills(this.#skills);
7364
+ }
7365
+ await this.refreshBaseSystemPrompt();
7366
+ this.#notifyCommandMetadataChanged();
7367
+ }
7368
+
6820
7369
  /**
6821
7370
  * Set active tools by name.
6822
7371
  * Only tools in the registry can be enabled. Unknown tool names are ignored.
@@ -6824,7 +7373,24 @@ export class AgentSession {
6824
7373
  * Changes take effect before the next model call.
6825
7374
  */
6826
7375
  async setActiveToolsByName(toolNames: string[]): Promise<void> {
6827
- await this.#applyActiveToolsByName(toolNames);
7376
+ const mounted = this.#mountedXdevToolNames;
7377
+ const normalized = normalizeToolNames(toolNames);
7378
+ const transportWriteActive =
7379
+ this.#builtInToolNames.has("write") &&
7380
+ this.getActiveToolNames().includes("write") &&
7381
+ this.#presentationPinnedToolNames?.has("write") !== true &&
7382
+ this.#runtimeSelectedToolNames?.has("write") !== true &&
7383
+ (mounted.size > 0 || this.#planModeState?.enabled === true);
7384
+ const previousRuntimeSelectedToolNames = this.#runtimeSelectedToolNames;
7385
+ this.#runtimeSelectedToolNames = new Set(
7386
+ normalized.filter(name => !mounted.has(name) && !(name === "write" && transportWriteActive)),
7387
+ );
7388
+ try {
7389
+ await this.#applyActiveToolsByName(normalized);
7390
+ } catch (error) {
7391
+ this.#runtimeSelectedToolNames = previousRuntimeSelectedToolNames;
7392
+ throw error;
7393
+ }
6828
7394
  }
6829
7395
 
6830
7396
  /** Rebuild the base system prompt using the current active tool set. */
@@ -6967,6 +7533,12 @@ export class AgentSession {
6967
7533
  */
6968
7534
  async refreshMCPTools(mcpTools: CustomTool[]): Promise<void> {
6969
7535
  const existingNames = Array.from(this.#toolRegistry.keys());
7536
+ const previousMcpTools = new Map(
7537
+ existingNames.flatMap(name => {
7538
+ const tool = this.#toolRegistry.get(name);
7539
+ return isMCPToolName(name) && tool ? [[name, tool] as const] : [];
7540
+ }),
7541
+ );
6970
7542
  for (const name of existingNames) {
6971
7543
  if (isMCPToolName(name)) {
6972
7544
  this.#toolRegistry.delete(name);
@@ -6994,10 +7566,18 @@ export class AgentSession {
6994
7566
  this.#toolRegistry.set(finalTool.name, finalTool);
6995
7567
  }
6996
7568
 
6997
- // Every connected MCP tool is enabled; re-derive the active set from the
6998
- // current non-MCP tools plus all freshly registered MCP tools.
7569
+ // Every connected MCP tool is selected; centralized repartitioning owns
7570
+ // presentation pins and write-transport activation/removal.
6999
7571
  const nextActive = [...new Set([...this.#getActiveNonMCPToolNames(), ...mcpTools.map(tool => tool.name)])];
7000
- await this.#applyActiveToolsByName(nextActive);
7572
+ try {
7573
+ await this.#applyActiveToolsByName(nextActive);
7574
+ } catch (error) {
7575
+ for (const name of this.#toolRegistry.keys()) {
7576
+ if (isMCPToolName(name)) this.#toolRegistry.delete(name);
7577
+ }
7578
+ for (const [name, tool] of previousMcpTools) this.#toolRegistry.set(name, tool);
7579
+ throw error;
7580
+ }
7001
7581
  }
7002
7582
 
7003
7583
  /**
@@ -7018,6 +7598,12 @@ export class AgentSession {
7018
7598
 
7019
7599
  const previousRpcHostToolNames = new Set(this.#rpcHostToolNames);
7020
7600
  const previousActiveToolNames = this.getEnabledToolNames();
7601
+ const previousRpcHostTools = new Map(
7602
+ [...previousRpcHostToolNames].flatMap(name => {
7603
+ const tool = this.#toolRegistry.get(name);
7604
+ return tool ? [[name, tool] as const] : [];
7605
+ }),
7606
+ );
7021
7607
  for (const name of previousRpcHostToolNames) {
7022
7608
  this.#toolRegistry.delete(name);
7023
7609
  }
@@ -7039,9 +7625,16 @@ export class AgentSession {
7039
7625
  const autoActivatedRpcToolNames = rpcTools
7040
7626
  .filter(tool => !tool.hidden && !previousRpcHostToolNames.has(tool.name))
7041
7627
  .map(tool => tool.name);
7042
- await this.#applyActiveToolsByName(
7043
- Array.from(new Set([...activeNonRpcToolNames, ...preservedRpcToolNames, ...autoActivatedRpcToolNames])),
7044
- );
7628
+ try {
7629
+ await this.#applyActiveToolsByName(
7630
+ Array.from(new Set([...activeNonRpcToolNames, ...preservedRpcToolNames, ...autoActivatedRpcToolNames])),
7631
+ );
7632
+ } catch (error) {
7633
+ for (const name of this.#rpcHostToolNames) this.#toolRegistry.delete(name);
7634
+ this.#rpcHostToolNames = previousRpcHostToolNames;
7635
+ for (const [name, tool] of previousRpcHostTools) this.#toolRegistry.set(name, tool);
7636
+ throw error;
7637
+ }
7045
7638
  }
7046
7639
 
7047
7640
  /** Whether auto-compaction is currently running */
@@ -7369,6 +7962,11 @@ export class AgentSession {
7369
7962
  .filter((tool): tool is AgentTool => tool !== undefined)
7370
7963
  .map(tool => this.#wrapToolForAcpPermission(tool));
7371
7964
  this.agent.setTools(activeTools);
7965
+ const mountedTools = [...this.#mountedXdevToolNames]
7966
+ .map(name => this.#toolRegistry.get(name))
7967
+ .filter((tool): tool is AgentTool => tool !== undefined)
7968
+ .map(tool => this.#wrapToolForAcpPermission(tool));
7969
+ this.#xdevRegistry?.reconcile(mountedTools);
7372
7970
  }
7373
7971
 
7374
7972
  #clearCheckpointRuntimeState(): void {
@@ -8030,7 +8628,7 @@ export class AgentSession {
8030
8628
  const generation = this.#promptGeneration;
8031
8629
  try {
8032
8630
  // Flush any pending bash messages before the new prompt
8033
- this.#flushPendingBashMessages();
8631
+ await this.#flushPendingBashMessages();
8034
8632
  this.#flushPendingPythonMessages();
8035
8633
  this.#flushPendingIrcAsides();
8036
8634
 
@@ -8264,8 +8862,11 @@ export class AgentSession {
8264
8862
  },
8265
8863
  hasPendingMessages: () => this.queuedMessageCount > 0,
8266
8864
  shutdown: () => {
8267
- void this.dispose();
8268
- process.exit(0);
8865
+ // Await the idempotent dispose() before exiting so the browser
8866
+ // reaper and other bounded teardown complete — a fire-and-forget
8867
+ // `void this.dispose()` raced process.exit() and could leave an
8868
+ // OMP-owned Chromium alive (#5643).
8869
+ void this.dispose().finally(() => process.exit(0));
8269
8870
  },
8270
8871
  getContextUsage: () => this.getContextUsage(),
8271
8872
  waitForIdle: () => this.waitForIdle(),
@@ -8301,9 +8902,20 @@ export class AgentSession {
8301
8902
  await this.reload();
8302
8903
  },
8303
8904
  getSystemPrompt: () => this.systemPrompt,
8905
+ setInterval: (callback, ms, ...args) => this.#fallbackTimers().setInterval(callback, ms, ...args),
8906
+ setTimeout: (callback, ms, ...args) => this.#fallbackTimers().setTimeout(callback, ms, ...args),
8907
+ clearTimer: timer => this.#fallbackTimers().clear(timer),
8304
8908
  };
8305
8909
  }
8306
8910
 
8911
+ /** Lazily create the runner-less command-context timer registry (#5664). */
8912
+ #fallbackTimers(): ManagedTimers {
8913
+ this.#fallbackExtensionTimers ??= new ManagedTimers((event, error) =>
8914
+ logger.warn("Extension timer callback threw", { event, error }),
8915
+ );
8916
+ return this.#fallbackExtensionTimers;
8917
+ }
8918
+
8307
8919
  /**
8308
8920
  * Try to execute a custom command. Returns the prompt string if found, null otherwise.
8309
8921
  * If the command returns void, returns empty string to indicate it was handled.
@@ -8937,16 +9549,26 @@ export class AgentSession {
8937
9549
  this.#replanTitleRefreshInFlight = refresh;
8938
9550
  }
8939
9551
 
8940
- async #refreshTitleAfterReplan(context: string, sessionId: string): Promise<void> {
8941
- const title = await generateSessionTitle(
8942
- context,
9552
+ /**
9553
+ * Generate an automatic session title tied to this session's lifecycle.
9554
+ * Input and replan callers share the signal so disposal cancels provider and
9555
+ * local-worker requests instead of leaving background inference alive.
9556
+ */
9557
+ generateTitle(firstMessage: string): Promise<string | null> {
9558
+ return generateSessionTitle(
9559
+ firstMessage,
8943
9560
  this.#modelRegistry,
8944
9561
  this.settings,
8945
- sessionId,
9562
+ this.sessionId,
8946
9563
  this.model,
8947
9564
  provider => this.agent.metadataForProvider(provider),
8948
9565
  this.#titleSystemPrompt,
9566
+ this.#titleGenerationAbortController.signal,
8949
9567
  );
9568
+ }
9569
+
9570
+ async #refreshTitleAfterReplan(context: string, sessionId: string): Promise<void> {
9571
+ const title = await this.generateTitle(context);
8950
9572
  if (!title) return;
8951
9573
  if (this.sessionManager.getSessionId() !== sessionId) return;
8952
9574
  if (!this.settings.get("title.refreshOnReplan")) return;
@@ -9014,6 +9636,7 @@ export class AgentSession {
9014
9636
  // auto-starting a fresh turn during cleanup.
9015
9637
  this.#abortInProgress = true;
9016
9638
  try {
9639
+ this.#abortAutolearnCapture();
9017
9640
  this.abortRetry();
9018
9641
  this.#promptGeneration++;
9019
9642
  this.#scheduledHiddenNextTurnGeneration = undefined;
@@ -9035,6 +9658,7 @@ export class AgentSession {
9035
9658
  this.agent.abort(options?.reason);
9036
9659
  await postPromptDrain;
9037
9660
  await this.agent.waitForIdle();
9661
+ await this.#drainAutolearnCapture();
9038
9662
  await this.#goalRuntime.onTaskAborted({ reason: options?.goalReason ?? "interrupted" });
9039
9663
  // Clear prompt-in-flight state: waitForIdle resolves when the agent loop's finally
9040
9664
  // block runs, but nested prompt setup/finalizers may still be unwinding. Without this,
@@ -9094,27 +9718,36 @@ export class AgentSession {
9094
9718
  await this.abort();
9095
9719
  this.#cancelOwnAsyncJobs();
9096
9720
  this.#closeAllProviderSessions("new session");
9097
- this.agent.reset();
9098
- if (options?.drop && previousSessionFile) {
9099
- // Detach the advisor recorder feed and drain its writer BEFORE deleting the
9100
- // old artifacts dir: `await this.abort()` only stops the primary, so a still-
9101
- // running advisor turn could otherwise finish, emit `message_end`, and recreate
9102
- // `<old>/__advisor.jsonl`. #resetAdvisorSessionState (after newSession) re-primes
9103
- // the advisor and re-attaches the feed at the new session's path.
9104
- for (const a of this.#advisors) {
9105
- a.agentUnsubscribe?.();
9106
- a.agentUnsubscribe = undefined;
9107
- await a.recorder.close();
9108
- }
9109
- try {
9110
- await this.sessionManager.dropSession(previousSessionFile);
9111
- } catch (err) {
9112
- logger.error("Failed to delete session during /drop", { err });
9721
+ await this.#flushPendingBashMessages();
9722
+ const bashTransition = this.#beginBashSessionTransition({ persistDetached: options?.drop !== true });
9723
+ let sessionTransitioned = false;
9724
+ try {
9725
+ this.agent.reset();
9726
+ if (options?.drop && previousSessionFile) {
9727
+ // Detach the advisor recorder feed and drain its writer BEFORE deleting the
9728
+ // old artifacts dir: `await this.abort()` only stops the primary, so a still-
9729
+ // running advisor turn could otherwise finish, emit `message_end`, and recreate
9730
+ // `<old>/__advisor.jsonl`. #resetAdvisorSessionState (after newSession) re-primes
9731
+ // the advisor and re-attaches the feed at the new session's path.
9732
+ for (const a of this.#advisors) {
9733
+ a.agentUnsubscribe?.();
9734
+ a.agentUnsubscribe = undefined;
9735
+ await a.recorder.close();
9736
+ }
9737
+ try {
9738
+ await this.sessionManager.dropSession(previousSessionFile);
9739
+ } catch (err) {
9740
+ logger.error("Failed to delete session during /drop", { err });
9741
+ }
9742
+ } else {
9743
+ await this.sessionManager.flush();
9113
9744
  }
9114
- } else {
9115
- await this.sessionManager.flush();
9745
+ await this.sessionManager.newSession(options);
9746
+ this.#markBashSessionTransition(bashTransition);
9747
+ sessionTransitioned = true;
9748
+ } finally {
9749
+ this.#finishBashSessionTransition(bashTransition, sessionTransitioned);
9116
9750
  }
9117
- await this.sessionManager.newSession(options);
9118
9751
 
9119
9752
  this.#clearCheckpointRuntimeState();
9120
9753
  this.setTodoPhases([]);
@@ -9180,14 +9813,25 @@ export class AgentSession {
9180
9813
  }
9181
9814
  }
9182
9815
 
9816
+ await this.#flushPendingBashMessages();
9183
9817
  // Flush current session to ensure all entries are written
9184
9818
  await this.sessionManager.flush();
9819
+ const bashTransition = this.#beginBashSessionTransition();
9185
9820
 
9186
9821
  // Fork the session (creates new session file with same entries)
9187
- const forkResult = await this.sessionManager.fork();
9822
+ let forkResult: { oldSessionFile: string; newSessionFile: string } | undefined;
9823
+ try {
9824
+ forkResult = await this.sessionManager.fork();
9825
+ } catch (error) {
9826
+ this.#finishBashSessionTransition(bashTransition, false);
9827
+ throw error;
9828
+ }
9188
9829
  if (!forkResult) {
9830
+ this.#finishBashSessionTransition(bashTransition, false);
9189
9831
  return false;
9190
9832
  }
9833
+ this.#markBashSessionTransition(bashTransition);
9834
+ this.#finishBashSessionTransition(bashTransition, true);
9191
9835
 
9192
9836
  // Copy artifacts directory if it exists
9193
9837
  const oldArtifactDir = forkResult.oldSessionFile.slice(0, -6);
@@ -10563,9 +11207,20 @@ export class AgentSession {
10563
11207
  return undefined;
10564
11208
  }
10565
11209
  }
11210
+ await this.#flushPendingBashMessages();
10566
11211
  await this.sessionManager.flush();
11212
+ const bashTransition = this.#beginBashSessionTransition();
10567
11213
  this.#cancelOwnAsyncJobs();
10568
- await this.sessionManager.newSession(previousSessionFile ? { parentSession: previousSessionFile } : undefined);
11214
+ let sessionTransitioned = false;
11215
+ try {
11216
+ await this.sessionManager.newSession(
11217
+ previousSessionFile ? { parentSession: previousSessionFile } : undefined,
11218
+ );
11219
+ this.#markBashSessionTransition(bashTransition);
11220
+ sessionTransitioned = true;
11221
+ } finally {
11222
+ this.#finishBashSessionTransition(bashTransition, sessionTransitioned);
11223
+ }
10569
11224
 
10570
11225
  this.#clearCheckpointRuntimeState();
10571
11226
  // agent.reset() clears the core steering/follow-up queues. Preserve any queued
@@ -11025,6 +11680,7 @@ export class AgentSession {
11025
11680
  autoContinue,
11026
11681
  triggerContextTokens: postMaintenanceContextTokens,
11027
11682
  phase: "pre_turn",
11683
+ terminalTextAnswer: isTerminalTextAssistantAnswer(assistantMessage),
11028
11684
  });
11029
11685
  }
11030
11686
  logger.debug("Auto-compaction threshold satisfied but context promotion took over", {
@@ -11458,11 +12114,13 @@ export class AgentSession {
11458
12114
 
11459
12115
  if (!branchEntry) return;
11460
12116
  const targetParentId = prunePrompt ? parentEntry.parentId : branchEntry.parentId;
11461
- if (targetParentId === null) {
11462
- this.sessionManager.resetLeaf();
11463
- } else {
11464
- this.sessionManager.branch(targetParentId);
11465
- }
12117
+ this.#withBashBranchTransition(() => {
12118
+ if (targetParentId === null) {
12119
+ this.sessionManager.resetLeaf();
12120
+ } else {
12121
+ this.sessionManager.branch(targetParentId);
12122
+ }
12123
+ });
11466
12124
  this.sessionManager.appendCustomEntry("accepted-terminal-empty-stop");
11467
12125
  }
11468
12126
 
@@ -11489,11 +12147,13 @@ export class AgentSession {
11489
12147
  if (!branchEntry) {
11490
12148
  return;
11491
12149
  }
11492
- if (branchEntry.parentId === null) {
11493
- this.sessionManager.resetLeaf();
11494
- } else {
11495
- this.sessionManager.branch(branchEntry.parentId);
11496
- }
12150
+ this.#withBashBranchTransition(() => {
12151
+ if (branchEntry.parentId === null) {
12152
+ this.sessionManager.resetLeaf();
12153
+ } else {
12154
+ this.sessionManager.branch(branchEntry.parentId);
12155
+ }
12156
+ });
11497
12157
  }
11498
12158
 
11499
12159
  #isSameAssistantMessage(left: AssistantMessage, right: AssistantMessage): boolean {
@@ -11530,8 +12190,10 @@ export class AgentSession {
11530
12190
  if (this.#pendingRewindReport) return this.#pendingRewindReport;
11531
12191
  for (let i = messages.length - 1; i >= 0; i--) {
11532
12192
  const message = messages[i];
11533
- if (message?.role !== "toolResult" || message.toolName !== "rewind" || message.isError) continue;
11534
- const details = message.details;
12193
+ if (message?.role !== "toolResult" || message.isError) continue;
12194
+ const semanticResult = semanticToolResult(message.toolName, message);
12195
+ if (semanticResult?.toolName !== "rewind") continue;
12196
+ const details = semanticResult.details;
11535
12197
  const detailReport =
11536
12198
  details && typeof details === "object" && "report" in details && typeof details.report === "string"
11537
12199
  ? details.report.trim()
@@ -11548,16 +12210,18 @@ export class AgentSession {
11548
12210
  if (!checkpointState) {
11549
12211
  return;
11550
12212
  }
11551
- try {
11552
- this.sessionManager.branchWithSummary(checkpointState.checkpointEntryId, report, {
11553
- startedAt: checkpointState.startedAt,
11554
- });
11555
- } catch (error) {
11556
- logger.warn("Rewind branch checkpoint missing, falling back to root", {
11557
- error: error instanceof Error ? error.message : String(error),
11558
- });
11559
- this.sessionManager.branchWithSummary(null, report, { startedAt: checkpointState.startedAt });
11560
- }
12213
+ this.#withBashBranchTransition(() => {
12214
+ try {
12215
+ this.sessionManager.branchWithSummary(checkpointState.checkpointEntryId, report, {
12216
+ startedAt: checkpointState.startedAt,
12217
+ });
12218
+ } catch (error) {
12219
+ logger.warn("Rewind branch checkpoint missing, falling back to root", {
12220
+ error: error instanceof Error ? error.message : String(error),
12221
+ });
12222
+ this.sessionManager.branchWithSummary(null, report, { startedAt: checkpointState.startedAt });
12223
+ }
12224
+ });
11561
12225
 
11562
12226
  const rewoundAt = new Date().toISOString();
11563
12227
  const details = { report, startedAt: checkpointState.startedAt, rewoundAt };
@@ -11572,7 +12236,7 @@ export class AgentSession {
11572
12236
 
11573
12237
  if (activeMessages) {
11574
12238
  for (const message of activeMessages) {
11575
- if (message.role === "toolResult" && message.toolName === "rewind") {
12239
+ if (message.role === "toolResult" && semanticToolResult(message.toolName, message)?.toolName === "rewind") {
11576
12240
  this.#rewoundToolResultIds.add(message.toolCallId);
11577
12241
  }
11578
12242
  }
@@ -12871,12 +13535,15 @@ export class AgentSession {
12871
13535
  suppressContinuation?: boolean;
12872
13536
  suppressHandoff?: boolean;
12873
13537
  phase?: CodexCompactionContext["phase"];
13538
+ terminalTextAnswer?: boolean;
12874
13539
  } = {},
12875
13540
  ): Promise<CompactionCheckResult> {
12876
13541
  const compactionSettings = this.settings.getGroup("compaction");
12877
13542
  if (compactionSettings.strategy === "off") return COMPACTION_CHECK_NONE;
12878
13543
  if (reason !== "idle" && !compactionSettings.enabled) return COMPACTION_CHECK_NONE;
12879
13544
  const generation = this.#promptGeneration;
13545
+ const terminalTextAnswer =
13546
+ options.terminalTextAnswer ?? isTerminalTextAssistantAnswer(this.#findLastAssistantMessage());
12880
13547
  const suppressContinuation = options.suppressContinuation === true;
12881
13548
  const shouldAutoContinue =
12882
13549
  !suppressContinuation && options.autoContinue !== false && compactionSettings.autoContinue !== false;
@@ -12891,6 +13558,7 @@ export class AgentSession {
12891
13558
  willRetry,
12892
13559
  generation,
12893
13560
  shouldAutoContinue,
13561
+ terminalTextAnswer,
12894
13562
  options.triggerContextTokens,
12895
13563
  suppressContinuation,
12896
13564
  );
@@ -12913,11 +13581,17 @@ export class AgentSession {
12913
13581
  async signal => {
12914
13582
  await Promise.resolve();
12915
13583
  if (signal.aborted) return;
12916
- await this.#runAutoCompaction(reason, willRetry, true, true, { phase: options.phase });
13584
+ await this.#runAutoCompaction(reason, willRetry, true, true, {
13585
+ ...options,
13586
+ terminalTextAnswer,
13587
+ });
12917
13588
  },
12918
13589
  { generation },
12919
13590
  );
12920
- return COMPACTION_CHECK_DEFERRED_HANDOFF;
13591
+ return {
13592
+ ...COMPACTION_CHECK_DEFERRED_HANDOFF,
13593
+ continuationScheduled: shouldAutoContinue,
13594
+ };
12921
13595
  }
12922
13596
 
12923
13597
  // "overflow" forces context-full because the input itself is broken — a handoff
@@ -12984,10 +13658,14 @@ export class AgentSession {
12984
13658
  aborted: false,
12985
13659
  willRetry: false,
12986
13660
  });
12987
- const continuationScheduled = !autoCompactionSignal.aborted && reason !== "idle" && shouldAutoContinue;
12988
- if (continuationScheduled) {
12989
- this.#scheduleAutoContinuePrompt(generation);
12990
- }
13661
+ const continuationScheduled =
13662
+ !autoCompactionSignal.aborted &&
13663
+ this.#scheduleCompactionContinuation({
13664
+ generation,
13665
+ autoContinue: reason !== "idle" && shouldAutoContinue,
13666
+ terminalTextAnswer,
13667
+ suppressContinuation,
13668
+ });
12991
13669
  return {
12992
13670
  ...(continuationScheduled ? COMPACTION_CHECK_CONTINUATION : COMPACTION_CHECK_NONE),
12993
13671
  historyRewritten: true,
@@ -13476,20 +14154,13 @@ export class AgentSession {
13476
14154
  if (retryFits) {
13477
14155
  this.#scheduleAgentContinue({ delayMs: 100, generation });
13478
14156
  continuationScheduled = true;
13479
- } else if (hasHeadroom && shouldAutoContinue) {
13480
- this.#scheduleAutoContinuePrompt(generation);
13481
- continuationScheduled = true;
13482
- }
13483
- if (!continuationScheduled && !suppressContinuation && this.agent.hasQueuedMessages()) {
13484
- // Auto-compaction can complete while follow-up/steering/custom messages are waiting.
13485
- // Kick the loop so queued messages are actually delivered. This remains separate
13486
- // from the no-progress warning: pausing maintenance must not strand user input.
13487
- this.#scheduleAgentContinue({
13488
- delayMs: 100,
14157
+ } else {
14158
+ continuationScheduled = this.#scheduleCompactionContinuation({
13489
14159
  generation,
13490
- shouldContinue: () => this.agent.hasQueuedMessages(),
14160
+ autoContinue: hasHeadroom && shouldAutoContinue,
14161
+ terminalTextAnswer,
14162
+ suppressContinuation,
13491
14163
  });
13492
- continuationScheduled = true;
13493
14164
  }
13494
14165
 
13495
14166
  if (deadEndWarning) {
@@ -13545,6 +14216,7 @@ export class AgentSession {
13545
14216
  willRetry: boolean,
13546
14217
  generation: number,
13547
14218
  autoContinue: boolean,
14219
+ terminalTextAnswer: boolean,
13548
14220
  triggerContextTokens?: number,
13549
14221
  suppressContinuation = false,
13550
14222
  ): Promise<CompactionCheckResult | "fallback"> {
@@ -13627,10 +14299,6 @@ export class AgentSession {
13627
14299
  });
13628
14300
 
13629
14301
  let continuationScheduled = false;
13630
- if (!willRetry && reason !== "idle" && autoContinue) {
13631
- this.#scheduleAutoContinuePrompt(generation);
13632
- continuationScheduled = true;
13633
- }
13634
14302
  if (willRetry) {
13635
14303
  // The shake rebuild replays every entry, so a trailing error/length
13636
14304
  // assistant from the failed turn re-enters agent state — drop it before
@@ -13646,13 +14314,13 @@ export class AgentSession {
13646
14314
  }
13647
14315
  this.#scheduleAgentContinue({ delayMs: 100, generation });
13648
14316
  continuationScheduled = true;
13649
- } else if (!suppressContinuation && this.agent.hasQueuedMessages()) {
13650
- this.#scheduleAgentContinue({
13651
- delayMs: 100,
14317
+ } else {
14318
+ continuationScheduled = this.#scheduleCompactionContinuation({
13652
14319
  generation,
13653
- shouldContinue: () => this.agent.hasQueuedMessages(),
14320
+ autoContinue: reason !== "idle" && autoContinue,
14321
+ terminalTextAnswer,
14322
+ suppressContinuation,
13654
14323
  });
13655
- continuationScheduled = true;
13656
14324
  }
13657
14325
  if (!reclaimed) {
13658
14326
  return willRetry && continuationScheduled
@@ -13806,6 +14474,11 @@ export class AgentSession {
13806
14474
  return stopType === "refusal" || stopType === "sensitive";
13807
14475
  }
13808
14476
 
14477
+ /** True when any registered model belongs to `provider`. */
14478
+ #hasProviderModels(provider: string): boolean {
14479
+ return this.#modelRegistry.getAll().some(model => model.provider === provider);
14480
+ }
14481
+
13809
14482
  #getRetryFallbackChains(): RetryFallbackChains {
13810
14483
  const configuredChains = this.settings.get("retry.fallbackChains");
13811
14484
  if (!configuredChains || typeof configuredChains !== "object") return {};
@@ -13836,8 +14509,8 @@ export class AgentSession {
13836
14509
  const keyKind = isRetryFallbackModelKey(key) ? "model" : "role";
13837
14510
  if (keyKind === "model") {
13838
14511
  if (isRetryFallbackWildcardKey(key)) {
13839
- const provider = key.slice(0, -2);
13840
- if (!this.#modelRegistry.getAll().some(model => model.provider === provider)) {
14512
+ const { provider } = parseRetryFallbackWildcard(key, p => this.#hasProviderModels(p));
14513
+ if (!this.#hasProviderModels(provider)) {
13841
14514
  const msg = `retry.fallbackChains wildcard key references unknown provider: ${key}`;
13842
14515
  logger.warn(msg);
13843
14516
  this.configWarnings.push(msg);
@@ -13869,8 +14542,8 @@ export class AgentSession {
13869
14542
  continue;
13870
14543
  }
13871
14544
  if (isRetryFallbackWildcardKey(selectorStr)) {
13872
- const provider = selectorStr.slice(0, -2);
13873
- if (!this.#modelRegistry.getAll().some(model => model.provider === provider)) {
14545
+ const { provider } = parseRetryFallbackWildcard(selectorStr, p => this.#hasProviderModels(p));
14546
+ if (!this.#hasProviderModels(provider)) {
13874
14547
  const msg = `Fallback chain for ${keyKind} '${key}' references unknown provider: ${selectorStr}`;
13875
14548
  logger.warn(msg);
13876
14549
  this.configWarnings.push(msg);
@@ -13929,13 +14602,16 @@ export class AgentSession {
13929
14602
  * Model-oriented keys win over roles so a chain follows the model across
13930
14603
  * role reassignments.
13931
14604
  */
13932
- #resolveRetryFallbackRole(currentSelector: string): string | undefined {
14605
+ #resolveRetryFallbackRole(
14606
+ currentSelector: string,
14607
+ currentModel: Model | null | undefined = this.model,
14608
+ ): string | undefined {
13933
14609
  const parsedCurrent = parseRetryFallbackSelector(currentSelector, this.#modelRegistry);
13934
14610
  if (!parsedCurrent) return undefined;
13935
14611
  const chains = this.#getRetryFallbackChains();
13936
14612
  const currentBaseSelector = formatRetryFallbackBaseSelector(parsedCurrent);
13937
- const currentPlainSelector = this.model
13938
- ? formatModelSelectorValue(formatModelString(this.model), parsedCurrent.thinkingLevel)
14613
+ const currentPlainSelector = currentModel
14614
+ ? formatModelSelectorValue(formatModelString(currentModel), parsedCurrent.thinkingLevel)
13939
14615
  : undefined;
13940
14616
  const currentPlainBaseSelector =
13941
14617
  currentPlainSelector && currentPlainSelector !== currentSelector
@@ -13961,9 +14637,22 @@ export class AgentSession {
13961
14637
  for (const key of exactModelKeys) {
13962
14638
  if (matchesCurrent(this.#getRetryFallbackPrimarySelector(key))) return key;
13963
14639
  }
13964
- // 2. Provider wildcard (`provider/*`) any active model of this provider.
13965
- const wildcardKey = `${parsedCurrent.provider}/*`;
13966
- if (Array.isArray(chains[wildcardKey])) return wildcardKey;
14640
+ // 2. Provider wildcardsan id-prefixed key (`openrouter/google/*`)
14641
+ // beats the plain `provider/*` key for ids under its prefix.
14642
+ let wildcardMatch: string | undefined;
14643
+ let wildcardPrefixLength = -1;
14644
+ for (const key in chains) {
14645
+ if (!isRetryFallbackWildcardKey(key) || !Array.isArray(chains[key])) continue;
14646
+ const { provider, idPrefix } = parseRetryFallbackWildcard(key, p => this.#hasProviderModels(p));
14647
+ if (provider !== parsedCurrent.provider) continue;
14648
+ if (idPrefix !== undefined && !parsedCurrent.id.startsWith(`${idPrefix}/`)) continue;
14649
+ const prefixLength = idPrefix === undefined ? 0 : idPrefix.length;
14650
+ if (prefixLength > wildcardPrefixLength) {
14651
+ wildcardMatch = key;
14652
+ wildcardPrefixLength = prefixLength;
14653
+ }
14654
+ }
14655
+ if (wildcardMatch) return wildcardMatch;
13967
14656
  // 3. Role keys — matched by the role's currently-assigned model.
13968
14657
  for (const key of roleKeys) {
13969
14658
  if (matchesCurrent(this.#getRetryFallbackPrimarySelector(key))) return key;
@@ -13982,9 +14671,11 @@ export class AgentSession {
13982
14671
 
13983
14672
  /**
13984
14673
  * Parse one configured chain entry. A `provider/*` entry keeps the failing
13985
- * model's id and swaps the provider (google-antigravity/x → google/x);
13986
- * ids the target provider lacks are skipped by the candidate loop's
13987
- * registry lookup.
14674
+ * model's id and swaps the provider (google-antigravity/x → google/x); an
14675
+ * id-prefixed `provider/prefix/*` entry re-prefixes the failing model's
14676
+ * bare id instead (openrouter/google/* : google-antigravity/x →
14677
+ * openrouter/google/x). Ids the target provider lacks are skipped by the
14678
+ * candidate loop's registry lookup.
13988
14679
  */
13989
14680
  #parseRetryFallbackChainEntry(
13990
14681
  entry: string,
@@ -13992,8 +14683,23 @@ export class AgentSession {
13992
14683
  ): RetryFallbackSelector | undefined {
13993
14684
  if (isRetryFallbackWildcardKey(entry)) {
13994
14685
  if (!current) return undefined;
13995
- const provider = entry.slice(0, -2);
13996
- return { raw: `${provider}/${current.id}`, provider, id: current.id, thinkingLevel: undefined };
14686
+ const { provider, idPrefix } = parseRetryFallbackWildcard(entry, p => this.#hasProviderModels(p));
14687
+ const bareId = current.id.slice(current.id.lastIndexOf("/") + 1);
14688
+ let id: string;
14689
+ if (idPrefix !== undefined) {
14690
+ id = `${idPrefix}/${bareId}`;
14691
+ } else if (
14692
+ bareId !== current.id &&
14693
+ !this.#modelRegistry.find(provider, current.id) &&
14694
+ this.#modelRegistry.find(provider, bareId)
14695
+ ) {
14696
+ // Aggregator → direct: the failing id carries a vendor prefix the
14697
+ // target provider does not use (openrouter/google/x → google-vertex/x).
14698
+ id = bareId;
14699
+ } else {
14700
+ id = current.id;
14701
+ }
14702
+ return { raw: `${provider}/${id}`, provider, id, thinkingLevel: undefined };
13997
14703
  }
13998
14704
  return parseRetryFallbackSelector(entry, this.#modelRegistry);
13999
14705
  }
@@ -14026,7 +14732,11 @@ export class AgentSession {
14026
14732
  return chain;
14027
14733
  }
14028
14734
 
14029
- #findRetryFallbackCandidates(role: string, currentSelector: string): RetryFallbackSelector[] {
14735
+ #findRetryFallbackCandidates(
14736
+ role: string,
14737
+ currentSelector: string,
14738
+ currentModel: Model | null | undefined = this.model,
14739
+ ): RetryFallbackSelector[] {
14030
14740
  let chain = this.#getRetryFallbackEffectiveChain(role, currentSelector);
14031
14741
  const parsedCurrent = parseRetryFallbackSelector(currentSelector, this.#modelRegistry);
14032
14742
  if (chain.length === 0 && role === "default" && parsedCurrent) {
@@ -14050,8 +14760,8 @@ export class AgentSession {
14050
14760
  if (chain.length <= 1) return [];
14051
14761
  const currentBaseSelector = parsedCurrent ? formatRetryFallbackBaseSelector(parsedCurrent) : undefined;
14052
14762
  const currentPlainSelector =
14053
- this.model && parsedCurrent
14054
- ? formatModelSelectorValue(formatModelString(this.model), parsedCurrent.thinkingLevel)
14763
+ currentModel && parsedCurrent
14764
+ ? formatModelSelectorValue(formatModelString(currentModel), parsedCurrent.thinkingLevel)
14055
14765
  : undefined;
14056
14766
  const currentPlainBaseSelector =
14057
14767
  parsedCurrent && currentPlainSelector && currentPlainSelector !== currentSelector
@@ -14661,17 +15371,187 @@ export class AgentSession {
14661
15371
  // Bash Execution
14662
15372
  // =========================================================================
14663
15373
 
14664
- async #saveBashOriginalArtifact(originalText: string): Promise<string | undefined> {
15374
+ async #saveBashOriginalArtifact(target: BashSessionTarget, originalText: string): Promise<string | undefined> {
14665
15375
  try {
14666
- return await this.sessionManager.saveArtifact(originalText, "bash-original");
15376
+ const destination = target.destination ?? (await target.pending);
15377
+ return await destination?.manager.saveArtifact(originalText, "bash-original");
14667
15378
  } catch {
14668
15379
  return undefined;
14669
15380
  }
14670
15381
  }
14671
15382
 
15383
+ #createBashMessage(
15384
+ command: string,
15385
+ result: BashResult,
15386
+ options?: { excludeFromContext?: boolean },
15387
+ ): BashExecutionMessage {
15388
+ const meta = outputMeta().truncationFromSummary(result, { direction: "tail" }).get();
15389
+ return {
15390
+ role: "bashExecution",
15391
+ command,
15392
+ output: result.output,
15393
+ exitCode: result.exitCode,
15394
+ cancelled: result.cancelled,
15395
+ truncated: result.truncated,
15396
+ meta,
15397
+ timestamp: Date.now(),
15398
+ excludeFromContext: options?.excludeFromContext,
15399
+ };
15400
+ }
15401
+
15402
+ #captureBashSessionTarget(): BashSessionTarget {
15403
+ this.#bashSessionTarget.refs++;
15404
+ return this.#bashSessionTarget;
15405
+ }
15406
+
15407
+ async #releaseBashSessionTarget(target: BashSessionTarget): Promise<void> {
15408
+ if (target.refs <= 0) throw new Error("Bash session target released more than once");
15409
+ target.refs--;
15410
+ if (target.refs === 0 && target.destination?.kind === "detached") {
15411
+ await target.destination.manager.close();
15412
+ }
15413
+ }
15414
+
15415
+ #appendBashMessage(destination: BashAppendDestination, message: BashExecutionMessage): void {
15416
+ switch (destination.kind) {
15417
+ case "current":
15418
+ this.agent.appendMessage(message);
15419
+ destination.manager.appendMessage(message);
15420
+ break;
15421
+ case "detached":
15422
+ destination.manager.appendMessage(message);
15423
+ break;
15424
+ case "branch":
15425
+ destination.parentId = destination.manager.appendMessageToBranch(message, destination.parentId);
15426
+ break;
15427
+ }
15428
+ }
15429
+
15430
+ async #appendOwnedBashMessage(target: BashSessionTarget, message: BashExecutionMessage): Promise<void> {
15431
+ try {
15432
+ const destination = target.destination ?? (await target.pending);
15433
+ if (!destination) throw new Error("Bash session target has no append destination");
15434
+ this.#appendBashMessage(destination, message);
15435
+ } finally {
15436
+ await this.#releaseBashSessionTarget(target);
15437
+ }
15438
+ }
15439
+
15440
+ async #recordBashResultForTarget(
15441
+ target: BashSessionTarget,
15442
+ command: string,
15443
+ result: BashResult,
15444
+ options?: { excludeFromContext?: boolean },
15445
+ ): Promise<void> {
15446
+ const message = this.#createBashMessage(command, result, options);
15447
+ if (this.isStreaming && target === this.#bashSessionTarget) {
15448
+ this.#pendingBashMessages.push({ target, message });
15449
+ return;
15450
+ }
15451
+ await this.#appendOwnedBashMessage(target, message);
15452
+ }
15453
+
15454
+ /** Run a leaf rewrite while retaining any in-flight bash on its originating branch. */
15455
+ #withBashBranchTransition<T>(mutate: () => T): T {
15456
+ const bashTransition = this.#beginBashSessionTransition();
15457
+ let branchTransitioned = false;
15458
+ try {
15459
+ const result = mutate();
15460
+ this.#markBashSessionTransition(bashTransition);
15461
+ branchTransitioned = true;
15462
+ return result;
15463
+ } finally {
15464
+ this.#finishBashSessionTransition(bashTransition, branchTransitioned);
15465
+ }
15466
+ }
15467
+
14672
15468
  /**
14673
- * Execute a bash command.
14674
- * Adds result to agent context and session.
15469
+ * Snapshot the session/branch that owns any in-flight bash before a transition.
15470
+ * When an owner is still active, its target is detached to a clone so a failed
15471
+ * or intentionally dropped transition never redirects the late result.
15472
+ */
15473
+ #beginBashSessionTransition(options?: { persistDetached?: boolean }): BashSessionTransition {
15474
+ const oldTarget = this.#bashSessionTarget;
15475
+ let detachedManager: SessionManager | undefined;
15476
+ let resolveOld: ((destination: BashAppendDestination) => void) | undefined;
15477
+ if (oldTarget.refs > 0) {
15478
+ detachedManager = this.sessionManager.cloneCurrentSession({ persist: options?.persistDetached });
15479
+ const pendingOld = Promise.withResolvers<BashAppendDestination>();
15480
+ oldTarget.destination = undefined;
15481
+ oldTarget.pending = pendingOld.promise;
15482
+ resolveOld = pendingOld.resolve;
15483
+ }
15484
+
15485
+ const pendingNew = Promise.withResolvers<BashAppendDestination>();
15486
+ return {
15487
+ oldTarget,
15488
+ newTarget: {
15489
+ sessionId: this.sessionManager.getSessionId(),
15490
+ refs: 0,
15491
+ pending: pendingNew.promise,
15492
+ },
15493
+ oldSessionId: this.sessionManager.getSessionId(),
15494
+ oldSessionFile: this.sessionManager.getSessionFile(),
15495
+ oldLeafId: this.sessionManager.getLeafId(),
15496
+ detachedManager,
15497
+ resolveOld,
15498
+ resolveNew: pendingNew.resolve,
15499
+ };
15500
+ }
15501
+
15502
+ /** Adopt the transition's new target as the live bash owner. */
15503
+ #markBashSessionTransition(transition: BashSessionTransition): void {
15504
+ transition.newTarget.sessionId = this.sessionManager.getSessionId();
15505
+ this.#bashSessionTarget = transition.newTarget;
15506
+ }
15507
+
15508
+ /**
15509
+ * Resolve the pending append destinations opened by {@link #beginBashSessionTransition}.
15510
+ * On success the old owner keeps its original session/branch (same file → current or
15511
+ * branch destination; different file → detached clone); on failure both fall back to
15512
+ * the still-current manager and the clone is discarded.
15513
+ */
15514
+ #finishBashSessionTransition(transition: BashSessionTransition, success: boolean): void {
15515
+ const currentDestination: BashAppendDestination = { kind: "current", manager: this.sessionManager };
15516
+ let oldDestination: BashAppendDestination = currentDestination;
15517
+ if (success && transition.resolveOld) {
15518
+ const currentFile = this.sessionManager.getSessionFile();
15519
+ const sameFile =
15520
+ transition.oldSessionFile === currentFile ||
15521
+ (transition.oldSessionFile !== undefined &&
15522
+ currentFile !== undefined &&
15523
+ path.resolve(transition.oldSessionFile) === path.resolve(currentFile));
15524
+ const sameSession = transition.oldSessionId === this.sessionManager.getSessionId() && sameFile;
15525
+ if (sameSession) {
15526
+ oldDestination =
15527
+ transition.oldLeafId === this.sessionManager.getLeafId()
15528
+ ? currentDestination
15529
+ : { kind: "branch", manager: this.sessionManager, parentId: transition.oldLeafId };
15530
+ } else if (transition.detachedManager) {
15531
+ oldDestination = { kind: "detached", manager: transition.detachedManager };
15532
+ }
15533
+ }
15534
+
15535
+ if (transition.resolveOld) {
15536
+ transition.oldTarget.pending = undefined;
15537
+ transition.oldTarget.destination = oldDestination;
15538
+ transition.resolveOld(oldDestination);
15539
+ }
15540
+
15541
+ transition.newTarget.pending = undefined;
15542
+ transition.newTarget.destination = currentDestination;
15543
+ if (!success) transition.newTarget.sessionId = this.sessionManager.getSessionId();
15544
+ transition.resolveNew(currentDestination);
15545
+
15546
+ if (transition.detachedManager && (oldDestination.kind !== "detached" || transition.oldTarget.refs === 0)) {
15547
+ void transition.detachedManager.close().catch(error => {
15548
+ logger.warn("Failed to close detached bash session writer", { error: String(error) });
15549
+ });
15550
+ }
15551
+ }
15552
+
15553
+ /**
15554
+ * Execute a bash command and retain the session/branch that owned its start.
14675
15555
  * @param command The bash command to execute
14676
15556
  * @param onChunk Optional streaming callback for output
14677
15557
  * @param options.excludeFromContext If true, command output won't be sent to LLM (!! prefix)
@@ -14682,72 +15562,72 @@ export class AgentSession {
14682
15562
  onChunk?: (chunk: string) => void,
14683
15563
  options?: { excludeFromContext?: boolean; useUserShell?: boolean },
14684
15564
  ): Promise<BashResult> {
15565
+ const target = this.#captureBashSessionTarget();
15566
+ let targetTransferred = false;
14685
15567
  const excludeFromContext = options?.excludeFromContext === true;
14686
15568
  const cwd = this.sessionManager.getCwd();
14687
15569
 
14688
- if (this.#extensionRunner?.hasHandlers("user_bash")) {
14689
- const hookResult = await this.#extensionRunner.emitUserBash({
14690
- type: "user_bash",
14691
- command,
14692
- excludeFromContext,
14693
- cwd,
14694
- });
14695
- if (hookResult?.result) {
14696
- this.recordBashResult(command, hookResult.result, options);
14697
- return hookResult.result;
15570
+ try {
15571
+ if (this.#extensionRunner?.hasHandlers("user_bash")) {
15572
+ const hookResult = await this.#extensionRunner.emitUserBash({
15573
+ type: "user_bash",
15574
+ command,
15575
+ excludeFromContext,
15576
+ cwd,
15577
+ });
15578
+ if (hookResult?.result) {
15579
+ targetTransferred = true;
15580
+ await this.#recordBashResultForTarget(target, command, hookResult.result, options);
15581
+ return hookResult.result;
15582
+ }
14698
15583
  }
14699
- }
14700
-
14701
- const abortController = new AbortController();
14702
- this.#bashAbortControllers.add(abortController);
14703
15584
 
14704
- try {
14705
- const result = await executeBashCommand(command, {
14706
- onChunk,
14707
- signal: abortController.signal,
14708
- sessionKey: this.sessionId,
14709
- cwd,
14710
- timeout: clampTimeout("bash") * 1000,
14711
- onMinimizedSave: originalText => this.#saveBashOriginalArtifact(originalText),
14712
- useUserShell: options?.useUserShell,
14713
- });
15585
+ const abortController = new AbortController();
15586
+ this.#bashAbortControllers.add(abortController);
15587
+ let result: BashResult;
15588
+ try {
15589
+ result = await executeBashCommand(command, {
15590
+ onChunk,
15591
+ signal: abortController.signal,
15592
+ sessionKey: target.sessionId,
15593
+ cwd,
15594
+ timeout: clampTimeout("bash") * 1000,
15595
+ onMinimizedSave: originalText => this.#saveBashOriginalArtifact(target, originalText),
15596
+ useUserShell: options?.useUserShell,
15597
+ });
15598
+ } finally {
15599
+ this.#bashAbortControllers.delete(abortController);
15600
+ }
14714
15601
 
14715
- this.recordBashResult(command, result, options);
15602
+ targetTransferred = true;
15603
+ await this.#recordBashResultForTarget(target, command, result, options);
14716
15604
  return result;
14717
15605
  } finally {
14718
- this.#bashAbortControllers.delete(abortController);
15606
+ if (!targetTransferred) await this.#releaseBashSessionTarget(target);
14719
15607
  }
14720
15608
  }
14721
15609
 
14722
- /**
14723
- * Record a bash execution result in session history.
14724
- * Used by executeBash and by extensions that handle bash execution themselves.
14725
- */
15610
+ /** Record a bash result supplied outside executeBash in the current ownership scope. */
14726
15611
  recordBashResult(command: string, result: BashResult, options?: { excludeFromContext?: boolean }): void {
14727
- const meta = outputMeta().truncationFromSummary(result, { direction: "tail" }).get();
14728
- const bashMessage: BashExecutionMessage = {
14729
- role: "bashExecution",
14730
- command,
14731
- output: result.output,
14732
- exitCode: result.exitCode,
14733
- cancelled: result.cancelled,
14734
- truncated: result.truncated,
14735
- meta,
14736
- timestamp: Date.now(),
14737
- excludeFromContext: options?.excludeFromContext,
14738
- };
14739
-
14740
- // If agent is streaming, defer adding to avoid breaking tool_use/tool_result ordering
14741
- if (this.isStreaming) {
14742
- // Queue for later - will be flushed on agent_end
14743
- this.#pendingBashMessages.push(bashMessage);
14744
- } else {
14745
- // Add to agent state immediately
14746
- this.agent.appendMessage(bashMessage);
15612
+ const target = this.#captureBashSessionTarget();
15613
+ const message = this.#createBashMessage(command, result, options);
15614
+ if (this.isStreaming && target === this.#bashSessionTarget) {
15615
+ this.#pendingBashMessages.push({ target, message });
15616
+ return;
15617
+ }
14747
15618
 
14748
- // Save to session
14749
- this.sessionManager.appendMessage(bashMessage);
15619
+ if (target.destination) {
15620
+ try {
15621
+ this.#appendBashMessage(target.destination, message);
15622
+ } finally {
15623
+ void this.#releaseBashSessionTarget(target);
15624
+ }
15625
+ return;
14750
15626
  }
15627
+
15628
+ void this.#appendOwnedBashMessage(target, message).catch(error => {
15629
+ logger.error("Failed to record bash result in its owning session", { error: String(error) });
15630
+ });
14751
15631
  }
14752
15632
 
14753
15633
  /**
@@ -14769,22 +15649,14 @@ export class AgentSession {
14769
15649
  return this.#pendingBashMessages.length > 0;
14770
15650
  }
14771
15651
 
14772
- /**
14773
- * Flush pending bash messages to agent state and session.
14774
- * Called after agent turn completes to maintain proper message ordering.
14775
- */
14776
- #flushPendingBashMessages(): void {
15652
+ /** Flush pending bash messages after the active turn without changing their ownership. */
15653
+ async #flushPendingBashMessages(): Promise<void> {
14777
15654
  if (this.#pendingBashMessages.length === 0) return;
14778
-
14779
- for (const bashMessage of this.#pendingBashMessages) {
14780
- // Add to agent state
14781
- this.agent.appendMessage(bashMessage);
14782
-
14783
- // Save to session
14784
- this.sessionManager.appendMessage(bashMessage);
14785
- }
14786
-
15655
+ const pending = this.#pendingBashMessages;
14787
15656
  this.#pendingBashMessages = [];
15657
+ for (const { target, message } of pending) {
15658
+ await this.#appendOwnedBashMessage(target, message);
15659
+ }
14788
15660
  }
14789
15661
 
14790
15662
  // =========================================================================
@@ -15377,9 +16249,11 @@ export class AgentSession {
15377
16249
  this.#disconnectFromAgent();
15378
16250
  await this.abort({ goalReason: "internal" });
15379
16251
 
16252
+ await this.#flushPendingBashMessages();
15380
16253
  // Flush pending writes before switching so restore snapshots reflect committed state.
15381
16254
  await this.sessionManager.flush();
15382
16255
  const previousSessionState = this.sessionManager.captureState();
16256
+ const bashTransition = this.#beginBashSessionTransition();
15383
16257
  // Only same-session reloads compare against the prior context to detect
15384
16258
  // rollback edits (`#didSessionMessagesChange` below). Building it for a
15385
16259
  // different-session switch is a pure waste — and on huge pre-fix sessions
@@ -15424,6 +16298,7 @@ export class AgentSession {
15424
16298
 
15425
16299
  try {
15426
16300
  await this.sessionManager.setSessionFile(sessionPath);
16301
+ this.#markBashSessionTransition(bashTransition);
15427
16302
  if (switchingToDifferentSession) {
15428
16303
  this.#freshProviderSessionId = undefined;
15429
16304
  this.#clearInheritedProviderPromptCacheKey();
@@ -15557,6 +16432,7 @@ export class AgentSession {
15557
16432
  error: String(error),
15558
16433
  });
15559
16434
  }
16435
+ this.#finishBashSessionTransition(bashTransition, true);
15560
16436
  return true;
15561
16437
  } catch (error) {
15562
16438
  this.sessionManager.restoreState(previousSessionState);
@@ -15588,6 +16464,7 @@ export class AgentSession {
15588
16464
  this.#syncTodoPhasesFromBranch();
15589
16465
  this.#resetAllAdvisorRuntimes();
15590
16466
  this.#reconnectToAgent();
16467
+ this.#finishBashSessionTransition(bashTransition, false);
15591
16468
  throw error;
15592
16469
  }
15593
16470
  }
@@ -15633,14 +16510,25 @@ export class AgentSession {
15633
16510
  this.#pendingNextTurnMessages = [];
15634
16511
  this.#scheduledHiddenNextTurnGeneration = undefined;
15635
16512
 
16513
+ await this.#flushPendingBashMessages();
15636
16514
  // Flush pending writes before branching
15637
16515
  await this.sessionManager.flush();
16516
+ const bashTransition = this.#beginBashSessionTransition();
15638
16517
  this.#cancelOwnAsyncJobs();
16518
+ this.#abortAutolearnCapture();
16519
+ await this.#drainAutolearnCapture();
15639
16520
 
15640
- if (!selectedEntry.parentId) {
15641
- await this.sessionManager.newSession({ parentSession: previousSessionFile });
15642
- } else {
15643
- this.sessionManager.createBranchedSession(selectedEntry.parentId);
16521
+ let sessionTransitioned = false;
16522
+ try {
16523
+ if (!selectedEntry.parentId) {
16524
+ await this.sessionManager.newSession({ parentSession: previousSessionFile });
16525
+ } else {
16526
+ this.sessionManager.createBranchedSession(selectedEntry.parentId);
16527
+ }
16528
+ this.#markBashSessionTransition(bashTransition);
16529
+ sessionTransitioned = true;
16530
+ } finally {
16531
+ this.#finishBashSessionTransition(bashTransition, sessionTransitioned);
15644
16532
  }
15645
16533
  this.#rehydrateCheckpointRewindState();
15646
16534
  this.#syncTodoPhasesFromBranch();
@@ -15724,10 +16612,21 @@ export class AgentSession {
15724
16612
  await this.abort({ goalReason: "internal", reason: "branching /btw" });
15725
16613
  this.agent.replaceQueues([], []);
15726
16614
  }
16615
+ await this.#flushPendingBashMessages();
15727
16616
  await this.sessionManager.flush();
16617
+ const bashTransition = this.#beginBashSessionTransition();
15728
16618
  this.#cancelOwnAsyncJobs();
16619
+ this.#abortAutolearnCapture();
16620
+ await this.#drainAutolearnCapture();
15729
16621
 
15730
- this.sessionManager.createBranchedSession(leafId);
16622
+ let sessionTransitioned = false;
16623
+ try {
16624
+ this.sessionManager.createBranchedSession(leafId);
16625
+ this.#markBashSessionTransition(bashTransition);
16626
+ sessionTransitioned = true;
16627
+ } finally {
16628
+ this.#finishBashSessionTransition(bashTransition, sessionTransitioned);
16629
+ }
15731
16630
 
15732
16631
  this.#rehydrateCheckpointRewindState();
15733
16632
  this.sessionManager.appendMessage({
@@ -15783,6 +16682,7 @@ export class AgentSession {
15783
16682
  /** Raw session context built during navigation — pass to renderInitialMessages to skip a second O(N) walk. */
15784
16683
  sessionContext?: SessionContext;
15785
16684
  }> {
16685
+ await this.#flushPendingBashMessages();
15786
16686
  const oldLeafId = this.sessionManager.getLeafId();
15787
16687
 
15788
16688
  // No-op if already at target
@@ -15910,18 +16810,28 @@ export class AgentSession {
15910
16810
 
15911
16811
  // Switch leaf (with or without summary)
15912
16812
  // Summary is attached at the navigation target position (newLeafId), not the old branch
16813
+ const bashTransition = this.#beginBashSessionTransition();
15913
16814
  let summaryEntry: BranchSummaryEntry | undefined;
15914
- if (summaryText) {
15915
- // Create summary at target position (can be null for root)
15916
- const summaryId = this.sessionManager.branchWithSummary(newLeafId, summaryText, summaryDetails, fromExtension);
15917
-
15918
- summaryEntry = this.sessionManager.getEntry(summaryId) as BranchSummaryEntry;
15919
- } else if (newLeafId === null) {
15920
- // No summary, navigating to root - reset leaf
15921
- this.sessionManager.resetLeaf();
15922
- } else {
15923
- // No summary, navigating to non-root
15924
- this.sessionManager.branch(newLeafId);
16815
+ let branchTransitioned = false;
16816
+ try {
16817
+ if (summaryText) {
16818
+ // Create summary at target position (can be null for root)
16819
+ const summaryId = this.sessionManager.branchWithSummary(
16820
+ newLeafId,
16821
+ summaryText,
16822
+ summaryDetails,
16823
+ fromExtension,
16824
+ );
16825
+ summaryEntry = this.sessionManager.getEntry(summaryId) as BranchSummaryEntry;
16826
+ } else if (newLeafId === null) {
16827
+ this.sessionManager.resetLeaf();
16828
+ } else {
16829
+ this.sessionManager.branch(newLeafId);
16830
+ }
16831
+ this.#markBashSessionTransition(bashTransition);
16832
+ branchTransitioned = true;
16833
+ } finally {
16834
+ this.#finishBashSessionTransition(bashTransition, branchTransitioned);
15925
16835
  }
15926
16836
 
15927
16837
  // Update agent state — build display context to populate agent messages.
@@ -16664,29 +17574,73 @@ export class AgentSession {
16664
17574
  return this.#advisors[0]?.agent;
16665
17575
  }
16666
17576
 
17577
+ /**
17578
+ * Lightweight advisor status for the status line: returns just the configured
17579
+ * flag and per-advisor name/status without computing token/cost breakdowns.
17580
+ * Avoids re-tokenizing the advisor transcript on every render frame.
17581
+ */
17582
+ getAdvisorStatusOverview(): { configured: boolean; advisors: { name: string; status: AdvisorRuntimeStatus }[] } {
17583
+ // Override stale map entries with live runtime status: failureNotified/quotaExhausted
17584
+ // clear on reset() but #advisorStatuses lags until the next build.
17585
+ const liveStatusBySlug = new Map<string, AdvisorRuntimeStatus>();
17586
+ for (const a of this.#advisors) {
17587
+ liveStatusBySlug.set(
17588
+ a.slug,
17589
+ a.runtime.quotaExhausted ? "quota_exhausted" : a.runtime.failureNotified ? "error" : "running",
17590
+ );
17591
+ }
17592
+ const advisors = [...this.#advisorStatuses.entries()].map(([slug, { name, status }]) => ({
17593
+ name,
17594
+ status: liveStatusBySlug.get(slug) ?? status,
17595
+ }));
17596
+ return { configured: this.#advisorEnabled, advisors };
17597
+ }
16667
17598
  /**
16668
17599
  * Return structured advisor stats for the status command and TUI panel.
16669
17600
  */
16670
17601
  getAdvisorStats(): AdvisorStats {
16671
17602
  const configured = this.#advisorEnabled;
16672
- const advisors = this.#advisors.map(a => this.#computeAdvisorStat(a));
16673
- if (advisors.length === 0) {
17603
+ const liveAdvisors = this.#advisors.map(a => this.#computeAdvisorStat(a));
17604
+ // Build the complete roster from #advisorStatuses, which already has the
17605
+ // correct de-duped slugs as keys. Live advisors (from #advisors) carry full
17606
+ // token/cost data; disabled/no-model/quota-exhausted advisors appear as
17607
+ // skeleton entries with just name + status so the status line renders a dot.
17608
+ const liveStatBySlug = new Map(this.#advisors.map((a, i) => [a.slug, liveAdvisors[i]]));
17609
+ const roster: PerAdvisorStat[] = [];
17610
+ for (const [slug, entry] of this.#advisorStatuses) {
17611
+ const live = liveStatBySlug.get(slug);
17612
+ if (live) {
17613
+ roster.push(live);
17614
+ } else {
17615
+ roster.push({
17616
+ name: entry.name,
17617
+ status: entry.status,
17618
+ contextWindow: 0,
17619
+ contextTokens: 0,
17620
+ tokens: { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
17621
+ cost: 0,
17622
+ messages: { user: 0, assistant: 0, total: 0 },
17623
+ });
17624
+ }
17625
+ }
17626
+ const active = liveAdvisors.length > 0;
17627
+ if (liveAdvisors.length === 0) {
16674
17628
  return {
16675
17629
  configured,
16676
- active: false,
17630
+ active,
16677
17631
  contextWindow: 0,
16678
17632
  contextTokens: 0,
16679
17633
  tokens: { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
16680
17634
  cost: 0,
16681
17635
  messages: { user: 0, assistant: 0, total: 0 },
16682
- advisors: [],
17636
+ advisors: roster,
16683
17637
  };
16684
17638
  }
16685
17639
  const tokens = { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0, total: 0 };
16686
17640
  const messages = { user: 0, assistant: 0, total: 0 };
16687
17641
  let cost = 0;
16688
17642
  let contextTokens = 0;
16689
- for (const a of advisors) {
17643
+ for (const a of liveAdvisors) {
16690
17644
  tokens.input += a.tokens.input;
16691
17645
  tokens.output += a.tokens.output;
16692
17646
  tokens.reasoning += a.tokens.reasoning;
@@ -16703,14 +17657,14 @@ export class AgentSession {
16703
17657
  // first advisor's so the legacy status line stays byte-identical.
16704
17658
  return {
16705
17659
  configured,
16706
- active: true,
16707
- model: advisors[0].model,
16708
- contextWindow: advisors[0].contextWindow,
17660
+ active,
17661
+ model: liveAdvisors[0].model,
17662
+ contextWindow: liveAdvisors[0].contextWindow,
16709
17663
  contextTokens,
16710
17664
  tokens,
16711
17665
  cost,
16712
17666
  messages,
16713
- advisors,
17667
+ advisors: roster,
16714
17668
  };
16715
17669
  }
16716
17670
 
@@ -16744,12 +17698,18 @@ export class AgentSession {
16744
17698
  }
16745
17699
  return {
16746
17700
  name: advisor.name,
17701
+ status: advisor.runtime.quotaExhausted
17702
+ ? "quota_exhausted"
17703
+ : advisor.runtime.failureNotified
17704
+ ? "error"
17705
+ : "running",
16747
17706
  model,
16748
17707
  contextWindow: model.contextWindow ?? 0,
16749
17708
  contextTokens,
16750
17709
  tokens: { input, output, reasoning, cacheRead, cacheWrite, total: totalTokens },
16751
17710
  cost,
16752
17711
  messages: { user, assistant, total: messages.length },
17712
+ sessionId: advisor.agent.sessionId,
16753
17713
  };
16754
17714
  }
16755
17715
 
@@ -16758,13 +17718,18 @@ export class AgentSession {
16758
17718
  */
16759
17719
  formatAdvisorStatus(): string {
16760
17720
  const stats = this.getAdvisorStats();
16761
- if (!stats.active) {
17721
+ if (!stats.active && stats.advisors.length === 0) {
16762
17722
  return stats.configured
16763
17723
  ? "Advisor setting is enabled, but no model is assigned to the 'advisor' role."
16764
17724
  : "Advisor is disabled.";
16765
17725
  }
16766
17726
  if (stats.advisors.length <= 1) {
16767
17727
  const s = stats.advisors[0];
17728
+ if (s && s.status === "no_model") {
17729
+ return stats.configured
17730
+ ? "Advisor setting is enabled, but no model is assigned to the 'advisor' role."
17731
+ : "Advisor is disabled.";
17732
+ }
16768
17733
  const contextLine =
16769
17734
  s.contextWindow > 0
16770
17735
  ? `Context: ${s.contextTokens.toLocaleString()} / ${s.contextWindow.toLocaleString()} tokens (${Math.round((s.contextTokens / s.contextWindow) * 100)}%)`
@@ -16773,6 +17738,7 @@ export class AgentSession {
16773
17738
  if (s.tokens.cacheRead > 0) spendParts.push(`${s.tokens.cacheRead.toLocaleString()} cache read`);
16774
17739
  if (s.tokens.cacheWrite > 0) spendParts.push(`${s.tokens.cacheWrite.toLocaleString()} cache write`);
16775
17740
  const spendLine = `Spend: ${spendParts.join(", ")}, $${s.cost.toFixed(4)}`;
17741
+ if (!s.model || s.status !== "running") return `Advisor "${s.name}" is ${s.status.replace("_", " ")}.`;
16776
17742
  return `Advisor is enabled (${s.model.provider}/${s.model.id}). ${contextLine}. ${spendLine}.`;
16777
17743
  }
16778
17744
  const lines = [`Advisors enabled (${stats.advisors.length}):`];
@@ -16781,7 +17747,9 @@ export class AgentSession {
16781
17747
  s.contextWindow > 0
16782
17748
  ? `${s.contextTokens.toLocaleString()} / ${s.contextWindow.toLocaleString()} (${Math.round((s.contextTokens / s.contextWindow) * 100)}%)`
16783
17749
  : `${s.contextTokens.toLocaleString()}`;
16784
- lines.push(` • ${s.name} (${s.model.provider}/${s.model.id}) — context ${ctx} tokens, $${s.cost.toFixed(4)}`);
17750
+ lines.push(
17751
+ ` • ${s.name}${s.model && s.status === "running" ? ` (${s.model.provider}/${s.model.id})` : ` [${s.status}]`} — context ${ctx} tokens, $${s.cost.toFixed(4)}`,
17752
+ );
16785
17753
  }
16786
17754
  lines.push(
16787
17755
  `Totals: ${stats.tokens.input.toLocaleString()} input, ${stats.tokens.output.toLocaleString()} output, $${stats.cost.toFixed(4)}.`,
@@ -16790,37 +17758,51 @@ export class AgentSession {
16790
17758
  }
16791
17759
 
16792
17760
  /**
16793
- * Estimate the advisor's current context tokens. When the advisor has a
16794
- * recent non-aborted assistant message with usage, use that prompt's token
16795
- * count and add a trailing estimate for messages after it. Otherwise estimate
16796
- * every message.
17761
+ * Estimate the advisor's current context tokens. A successful provider usage
17762
+ * after the latest advisor compaction is ground truth for the prompt plus its
17763
+ * generated output; only messages after that anchor are estimated. Usage from
17764
+ * retained pre-compaction messages is stale and must not immediately retrigger
17765
+ * maintenance on the newly compacted context.
16797
17766
  */
16798
17767
  #estimateAdvisorContextTokens(messages: AgentMessage[]): number {
16799
- let lastUsageIndex: number | null = null;
16800
- let lastUsage: AssistantMessage["usage"] | undefined;
17768
+ let usageAnchorStartIndex = 0;
16801
17769
  for (let i = messages.length - 1; i >= 0; i--) {
16802
- const msg = messages[i];
16803
- if (msg.role === "assistant") {
16804
- const assistantMsg = msg as AssistantMessage;
16805
- if (assistantMsg.stopReason !== "aborted" && assistantMsg.stopReason !== "error" && assistantMsg.usage) {
16806
- lastUsage = assistantMsg.usage;
16807
- lastUsageIndex = i;
16808
- break;
16809
- }
17770
+ const message = messages[i];
17771
+ if (message.role !== "compactionSummary") continue;
17772
+ const advisorSummary = message as AdvisorCompactionSummaryMessage;
17773
+ // Advisor summaries created before this runtime-only boundary existed have
17774
+ // no trustworthy way to distinguish retained from newly appended messages.
17775
+ // Conservatively ignore every current assistant until the next compaction.
17776
+ usageAnchorStartIndex = advisorSummary.advisorUsageAnchorStartIndex ?? messages.length;
17777
+ break;
17778
+ }
17779
+
17780
+ let lastUsageIndex: number | undefined;
17781
+ let lastUsage: AssistantMessage["usage"] | undefined;
17782
+ for (let i = messages.length - 1; i >= usageAnchorStartIndex; i--) {
17783
+ const message = messages[i];
17784
+ if (message.role !== "assistant") continue;
17785
+ const assistant = message as AssistantMessage;
17786
+ if (assistant.stopReason !== "aborted" && assistant.stopReason !== "error" && assistant.usage) {
17787
+ lastUsage = assistant.usage;
17788
+ lastUsageIndex = i;
17789
+ break;
16810
17790
  }
16811
17791
  }
16812
- if (!lastUsage || lastUsageIndex === null) {
17792
+
17793
+ const estimateOptions = { excludeEncryptedReasoning: true } as const;
17794
+ if (!lastUsage || lastUsageIndex === undefined) {
16813
17795
  let estimated = 0;
16814
17796
  for (const message of messages) {
16815
- estimated += estimateTokens(message);
17797
+ estimated += estimateTokens(message, estimateOptions);
16816
17798
  }
16817
17799
  return estimated;
16818
17800
  }
16819
17801
  let trailingTokens = 0;
16820
17802
  for (let i = lastUsageIndex + 1; i < messages.length; i++) {
16821
- trailingTokens += estimateTokens(messages[i]);
17803
+ trailingTokens += estimateTokens(messages[i], estimateOptions);
16822
17804
  }
16823
- return calculatePromptTokens(lastUsage) + trailingTokens;
17805
+ return calculateContextTokens(lastUsage) + trailingTokens;
16824
17806
  }
16825
17807
 
16826
17808
  /**