@oh-my-pi/pi-coding-agent 17.0.1 → 17.0.3

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 (179) hide show
  1. package/CHANGELOG.md +162 -40
  2. package/dist/cli.js +4559 -4543
  3. package/dist/types/advisor/config.d.ts +14 -6
  4. package/dist/types/advisor/runtime.d.ts +20 -11
  5. package/dist/types/autolearn/controller.d.ts +1 -0
  6. package/dist/types/config/model-discovery.d.ts +17 -0
  7. package/dist/types/config/model-resolver.d.ts +6 -2
  8. package/dist/types/config/settings-schema.d.ts +39 -10
  9. package/dist/types/config/settings.d.ts +50 -0
  10. package/dist/types/cursor.d.ts +11 -0
  11. package/dist/types/discovery/helpers.d.ts +5 -2
  12. package/dist/types/eval/agent-bridge.d.ts +7 -19
  13. package/dist/types/exec/bash-executor.d.ts +2 -0
  14. package/dist/types/extensibility/extensions/managed-timers.d.ts +15 -0
  15. package/dist/types/extensibility/extensions/runner.d.ts +7 -0
  16. package/dist/types/extensibility/extensions/types.d.ts +18 -0
  17. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +55 -1
  18. package/dist/types/extensibility/shared-events.d.ts +6 -0
  19. package/dist/types/extensibility/utils.d.ts +2 -2
  20. package/dist/types/lsp/client.d.ts +2 -0
  21. package/dist/types/lsp/types.d.ts +3 -0
  22. package/dist/types/mcp/transports/stdio.d.ts +42 -1
  23. package/dist/types/modes/components/advisor-config.d.ts +8 -1
  24. package/dist/types/modes/components/agent-hub.d.ts +15 -0
  25. package/dist/types/modes/components/hook-editor.d.ts +7 -0
  26. package/dist/types/modes/components/model-hub.d.ts +5 -2
  27. package/dist/types/modes/components/session-selector.d.ts +2 -0
  28. package/dist/types/modes/controllers/command-controller.d.ts +8 -0
  29. package/dist/types/modes/controllers/selector-controller.d.ts +3 -1
  30. package/dist/types/modes/print-mode.d.ts +1 -1
  31. package/dist/types/modes/warp-events.d.ts +24 -0
  32. package/dist/types/modes/warp-events.test.d.ts +1 -0
  33. package/dist/types/plan-mode/model-transition.d.ts +47 -0
  34. package/dist/types/plan-mode/model-transition.test.d.ts +1 -0
  35. package/dist/types/registry/agent-lifecycle.d.ts +26 -1
  36. package/dist/types/registry/persisted-agents.d.ts +3 -0
  37. package/dist/types/sdk.d.ts +27 -5
  38. package/dist/types/session/agent-session.d.ts +34 -10
  39. package/dist/types/session/session-entries.d.ts +6 -1
  40. package/dist/types/session/session-history-format.d.ts +10 -0
  41. package/dist/types/session/session-manager.d.ts +19 -0
  42. package/dist/types/slash-commands/helpers/active-oauth-account.d.ts +9 -0
  43. package/dist/types/task/executor.d.ts +26 -1
  44. package/dist/types/task/index.d.ts +1 -1
  45. package/dist/types/task/label.d.ts +1 -1
  46. package/dist/types/task/parallel.d.ts +14 -0
  47. package/dist/types/task/structured-subagent.d.ts +111 -0
  48. package/dist/types/task/types.d.ts +51 -0
  49. package/dist/types/telemetry-export.d.ts +34 -9
  50. package/dist/types/tools/approval.d.ts +8 -0
  51. package/dist/types/tools/bash.d.ts +2 -0
  52. package/dist/types/tools/essential-tools.d.ts +29 -0
  53. package/dist/types/tools/fetch.d.ts +4 -5
  54. package/dist/types/tools/hub/messaging.d.ts +5 -1
  55. package/dist/types/tools/image-gen.d.ts +2 -1
  56. package/dist/types/tools/index.d.ts +17 -1
  57. package/dist/types/tools/todo.d.ts +31 -0
  58. package/dist/types/tools/xdev.d.ts +11 -2
  59. package/dist/types/tui/tree-list.d.ts +7 -0
  60. package/dist/types/utils/markit.d.ts +11 -0
  61. package/dist/types/utils/title-generator.d.ts +2 -1
  62. package/dist/types/web/search/providers/kimi.d.ts +4 -1
  63. package/dist/types/web/search/types.d.ts +1 -1
  64. package/package.json +21 -16
  65. package/src/advisor/__tests__/advisor.test.ts +1304 -42
  66. package/src/advisor/__tests__/config.test.ts +58 -2
  67. package/src/advisor/config.ts +76 -24
  68. package/src/advisor/runtime.ts +445 -92
  69. package/src/autolearn/controller.ts +23 -28
  70. package/src/cli/file-processor.ts +1 -2
  71. package/src/cli.ts +5 -1
  72. package/src/config/model-discovery.ts +81 -21
  73. package/src/config/model-registry.ts +25 -6
  74. package/src/config/model-resolver.ts +14 -7
  75. package/src/config/settings-schema.ts +46 -9
  76. package/src/config/settings.ts +405 -25
  77. package/src/cursor.ts +20 -3
  78. package/src/debug/report-bundle.ts +40 -4
  79. package/src/discovery/helpers.ts +28 -5
  80. package/src/eval/__tests__/agent-bridge.test.ts +133 -47
  81. package/src/eval/__tests__/prelude-agent.test.ts +29 -0
  82. package/src/eval/agent-bridge.ts +104 -477
  83. package/src/eval/jl/prelude.jl +7 -6
  84. package/src/eval/js/shared/prelude.txt +5 -4
  85. package/src/eval/py/prelude.py +11 -39
  86. package/src/eval/rb/prelude.rb +5 -6
  87. package/src/exec/bash-executor.ts +14 -5
  88. package/src/extensibility/custom-tools/loader.ts +3 -3
  89. package/src/extensibility/custom-tools/wrapper.ts +2 -1
  90. package/src/extensibility/extensions/loader.ts +3 -3
  91. package/src/extensibility/extensions/managed-timers.ts +83 -0
  92. package/src/extensibility/extensions/runner.ts +26 -0
  93. package/src/extensibility/extensions/types.ts +18 -0
  94. package/src/extensibility/extensions/wrapper.ts +2 -1
  95. package/src/extensibility/hooks/loader.ts +3 -3
  96. package/src/extensibility/legacy-pi-coding-agent-shim.ts +149 -8
  97. package/src/extensibility/plugins/legacy-pi-compat.ts +225 -22
  98. package/src/extensibility/plugins/manager.ts +2 -2
  99. package/src/extensibility/shared-events.ts +6 -0
  100. package/src/extensibility/utils.ts +91 -25
  101. package/src/irc/bus.ts +22 -3
  102. package/src/launch/broker.ts +3 -2
  103. package/src/launch/client.ts +2 -2
  104. package/src/launch/presence.ts +2 -2
  105. package/src/lsp/client.ts +58 -1
  106. package/src/lsp/index.ts +62 -6
  107. package/src/lsp/types.ts +3 -0
  108. package/src/main.ts +11 -8
  109. package/src/mcp/manager.ts +9 -3
  110. package/src/mcp/oauth-flow.ts +20 -0
  111. package/src/mcp/transports/stdio.test.ts +269 -1
  112. package/src/mcp/transports/stdio.ts +255 -24
  113. package/src/modes/components/advisor-config.ts +65 -3
  114. package/src/modes/components/agent-hub.ts +1 -72
  115. package/src/modes/components/ask-dialog.ts +1 -1
  116. package/src/modes/components/bash-execution.ts +7 -3
  117. package/src/modes/components/eval-execution.ts +3 -1
  118. package/src/modes/components/hook-editor.ts +18 -3
  119. package/src/modes/components/model-hub.ts +138 -42
  120. package/src/modes/components/session-selector.ts +4 -0
  121. package/src/modes/components/status-line/component.test.ts +1 -0
  122. package/src/modes/components/status-line/segments.ts +21 -6
  123. package/src/modes/controllers/command-controller.ts +167 -47
  124. package/src/modes/controllers/event-controller.ts +5 -0
  125. package/src/modes/controllers/extension-ui-controller.ts +4 -22
  126. package/src/modes/controllers/input-controller.ts +12 -12
  127. package/src/modes/controllers/selector-controller.ts +191 -31
  128. package/src/modes/interactive-mode.ts +169 -62
  129. package/src/modes/print-mode.ts +3 -3
  130. package/src/modes/rpc/host-tools.ts +2 -1
  131. package/src/modes/rpc/rpc-mode.ts +19 -4
  132. package/src/modes/warp-events.test.ts +794 -0
  133. package/src/modes/warp-events.ts +232 -0
  134. package/src/plan-mode/model-transition.test.ts +60 -0
  135. package/src/plan-mode/model-transition.ts +51 -0
  136. package/src/prompts/system/system-prompt.md +1 -1
  137. package/src/prompts/tools/eval.md +5 -2
  138. package/src/prompts/tools/read.md +1 -1
  139. package/src/prompts/tools/task.md +12 -8
  140. package/src/prompts/tools/write.md +1 -1
  141. package/src/registry/agent-lifecycle.ts +133 -18
  142. package/src/registry/persisted-agents.ts +74 -0
  143. package/src/sdk.ts +343 -122
  144. package/src/session/agent-session.ts +1359 -368
  145. package/src/session/session-entries.ts +6 -1
  146. package/src/session/session-history-format.ts +20 -5
  147. package/src/session/session-manager.ts +57 -0
  148. package/src/session/streaming-output.ts +41 -1
  149. package/src/slash-commands/builtin-registry.ts +7 -0
  150. package/src/slash-commands/helpers/active-oauth-account.ts +16 -0
  151. package/src/system-prompt.ts +7 -2
  152. package/src/task/executor.ts +100 -22
  153. package/src/task/index.ts +258 -429
  154. package/src/task/label.ts +2 -0
  155. package/src/task/parallel.ts +43 -0
  156. package/src/task/persisted-revive.ts +19 -7
  157. package/src/task/structured-subagent.ts +642 -0
  158. package/src/task/types.ts +58 -0
  159. package/src/telemetry-export.ts +453 -97
  160. package/src/tools/__tests__/eval-description.test.ts +1 -1
  161. package/src/tools/approval.ts +11 -0
  162. package/src/tools/bash.ts +71 -38
  163. package/src/tools/essential-tools.ts +45 -0
  164. package/src/tools/fetch.ts +28 -105
  165. package/src/tools/gh.ts +169 -2
  166. package/src/tools/hub/messaging.ts +16 -3
  167. package/src/tools/image-gen.ts +69 -7
  168. package/src/tools/index.ts +50 -15
  169. package/src/tools/path-utils.ts +1 -0
  170. package/src/tools/read.ts +62 -29
  171. package/src/tools/todo.ts +126 -13
  172. package/src/tools/write.ts +22 -4
  173. package/src/tools/xdev.ts +14 -3
  174. package/src/tui/tree-list.ts +39 -0
  175. package/src/utils/markit.ts +12 -0
  176. package/src/utils/title-generator.ts +15 -4
  177. package/src/utils/zip.ts +16 -1
  178. package/src/web/search/providers/kimi.ts +18 -12
  179. 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,
@@ -197,6 +198,7 @@ import {
197
198
  onModelRolesChanged,
198
199
  validateProviderMaxInFlightRequests,
199
200
  } from "../config/settings";
201
+ import { CursorExecHandlers } from "../cursor";
200
202
  import { RawSseDebugBuffer } from "../debug/raw-sse-buffer";
201
203
  import { expandApplyPatchToEntries, normalizeDiff, normalizeToLF, ParseError, previewPatch, stripBom } from "../edit";
202
204
  import { getFileSnapshotStore } from "../edit/file-snapshot-store";
@@ -233,6 +235,7 @@ import type {
233
235
  TurnEndEvent,
234
236
  TurnStartEvent,
235
237
  } from "../extensibility/extensions";
238
+ import { ManagedTimers } from "../extensibility/extensions/managed-timers";
236
239
  import { createExtensionModelQuery } from "../extensibility/extensions/model-api";
237
240
  import type { CompactOptions, ContextUsage } from "../extensibility/extensions/types";
238
241
  import { ExtensionToolWrapper } from "../extensibility/extensions/wrapper";
@@ -252,7 +255,11 @@ import { containsOrchestrate, ORCHESTRATE_NOTICE } from "../modes/orchestrate";
252
255
  import { theme } from "../modes/theme/theme";
253
256
  import { parseTurnBudget } from "../modes/turn-budget";
254
257
  import { containsUltrathink, ULTRATHINK_NOTICE } from "../modes/ultrathink";
255
- import { computeNonMessageBreakdown, computeNonMessageTokens } from "../modes/utils/context-usage";
258
+ import {
259
+ computeNonMessageBreakdown,
260
+ computeNonMessageTokens,
261
+ estimateToolSchemaTokens,
262
+ } from "../modes/utils/context-usage";
256
263
  import { containsWorkflow, renderWorkflowNotice } from "../modes/workflow";
257
264
  import { resolveApprovedPlan } from "../plan-mode/approved-plan";
258
265
  import { createPlanReadMatcher } from "../plan-mode/plan-protection";
@@ -393,6 +400,34 @@ import { YieldQueue } from "./yield-queue";
393
400
  const SESSION_STOP_CONTINUATION_CAP = 8;
394
401
  const PLAN_MODE_REMINDER_MAX = 3;
395
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
+
396
431
  /**
397
432
  * Mutating tool results (`bash`/`eval`/`edit`/`write`/`ast_edit`) without the
398
433
  * agent touching the `todo` tool that trip the mid-run reconciliation nudge.
@@ -531,6 +566,43 @@ function reportFromRewindReportContent(content: string): string {
531
566
  return report.trim();
532
567
  }
533
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
+
534
606
  function completedRewindFromEntry(entry: SessionEntry): CompletedRewindState | undefined {
535
607
  if (entry.type !== "custom_message" || entry.customType !== "rewind-report") return undefined;
536
608
  const details = entry.details;
@@ -543,21 +615,18 @@ function completedRewindFromEntry(entry: SessionEntry): CompletedRewindState | u
543
615
  reportFromRewindReportContent(customMessageContentText(entry.content));
544
616
  return report.length > 0 ? { report, startedAt, rewoundAt } : undefined;
545
617
  }
546
-
547
- function isSuccessfulCheckpointEntry(entry: SessionEntry): entry is SessionMessageEntry & {
548
- message: { role: "toolResult"; toolName: "checkpoint"; isError?: false };
549
- } {
550
- return (
551
- entry.type === "message" &&
552
- entry.message.role === "toolResult" &&
553
- entry.message.toolName === "checkpoint" &&
554
- entry.message.isError !== true
555
- );
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";
556
625
  }
557
626
 
558
627
  function checkpointStartedAtFromEntry(entry: SessionEntry): string | undefined {
559
628
  if (!isSuccessfulCheckpointEntry(entry)) return undefined;
560
- const details = entry.message.details;
629
+ const details = semanticToolResult(entry.message.toolName, entry.message)?.details;
561
630
  if (details && typeof details === "object") {
562
631
  const startedAt = stringProperty(details, "startedAt");
563
632
  if (startedAt) return startedAt;
@@ -671,7 +740,7 @@ const COMPACTION_CHECK_NONE: CompactionCheckResult = {
671
740
  };
672
741
  const COMPACTION_CHECK_DEFERRED_HANDOFF: CompactionCheckResult = {
673
742
  deferredHandoff: true,
674
- continuationScheduled: true,
743
+ continuationScheduled: false,
675
744
  };
676
745
  const COMPACTION_CHECK_CONTINUATION: CompactionCheckResult = {
677
746
  deferredHandoff: false,
@@ -843,6 +912,8 @@ export interface AgentSessionConfig {
843
912
  builtInToolNames?: Iterable<string>;
844
913
  /** Update tool-session predicates that render guidance from the live active tool set. */
845
914
  setActiveToolNames?: (names: Iterable<string>) => void;
915
+ /** Register the write transport lazily when runtime xdev mounts first need it. */
916
+ ensureWriteRegistered?: () => Promise<boolean>;
846
917
  /** Current session pre-LLM message transform pipeline */
847
918
  transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => AgentMessage[] | Promise<AgentMessage[]>;
848
919
  /**
@@ -889,7 +960,8 @@ export interface AgentSessionConfig {
889
960
  xdevRegistry?: XdevRegistry;
890
961
  /** Discoverable tools mounted under `xd://` in the initial enabled set (startup partition in `sdk.ts`). */
891
962
  initialMountedXdevToolNames?: string[];
892
- requestedToolNames?: ReadonlySet<string>;
963
+ /** Explicit/effective names pinned top-level during runtime repartitioning. */
964
+ presentationPinnedToolNames?: ReadonlySet<string>;
893
965
  /**
894
966
  * Optional accessor for live MCP server instructions. Read by the session's
895
967
  * `rebuildSystemPrompt`-skip optimization to detect server-side instruction
@@ -1117,15 +1189,20 @@ export interface AdvisorStats {
1117
1189
  advisors: PerAdvisorStat[];
1118
1190
  }
1119
1191
 
1120
- /** 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. */
1121
1196
  export interface PerAdvisorStat {
1122
1197
  name: string;
1123
- model: Model;
1198
+ status: AdvisorRuntimeStatus;
1199
+ model?: Model;
1124
1200
  contextWindow: number;
1125
1201
  contextTokens: number;
1126
1202
  tokens: AdvisorStats["tokens"];
1127
1203
  cost: number;
1128
1204
  messages: AdvisorStats["messages"];
1205
+ sessionId?: string;
1129
1206
  }
1130
1207
 
1131
1208
  /**
@@ -1134,6 +1211,13 @@ export interface PerAdvisorStat {
1134
1211
  * primary-scoped state (turn counters, interrupt latches, the shared yield
1135
1212
  * channel) stays on the session.
1136
1213
  */
1214
+ interface AdvisorRetryFallbackState {
1215
+ role: string;
1216
+ originalSelector: string;
1217
+ originalThinkingLevel: ThinkingLevel;
1218
+ lastAppliedThinkingLevel: ThinkingLevel;
1219
+ }
1220
+
1137
1221
  interface ActiveAdvisor {
1138
1222
  /** Display name from config ("default" for the legacy no-YAML advisor). */
1139
1223
  name: string;
@@ -1150,10 +1234,23 @@ interface ActiveAdvisor {
1150
1234
  agentUnsubscribe?: () => void;
1151
1235
  model: Model;
1152
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;
1153
1243
  /** Stable key for the resolved runtime inputs that require a rebuild to change. */
1154
1244
  signature: string;
1155
1245
  }
1156
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
+
1157
1254
  /** Resolved advisor config ready to instantiate as an {@link ActiveAdvisor}. */
1158
1255
  interface AdvisorRuntimeDescriptor {
1159
1256
  config: AdvisorConfig;
@@ -1229,13 +1326,31 @@ function isRetryFallbackModelKey(key: string): boolean {
1229
1326
  }
1230
1327
 
1231
1328
  /**
1232
- * A `provider/*` fallback-chain key: matches any active model of that provider,
1233
- * 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.
1234
1333
  */
1235
1334
  function isRetryFallbackWildcardKey(key: string): boolean {
1236
1335
  return key.endsWith("/*");
1237
1336
  }
1238
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
+
1239
1354
  function formatRetryFallbackSelector(model: Model, thinkingLevel: ThinkingLevel | undefined): string {
1240
1355
  return formatModelSelectorValue(formatModelStringWithRouting(model), thinkingLevel);
1241
1356
  }
@@ -1797,6 +1912,11 @@ export class AgentSession {
1797
1912
  #advisors: ActiveAdvisor[] = [];
1798
1913
  /** Configured advisor roster from WATCHDOG.yml; undefined/empty → single legacy advisor. */
1799
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();
1800
1920
  /** Provider-facing UUIDv7 identities keyed by primary provider session and advisor slug. */
1801
1921
  #advisorProviderSessionIds = new Map<string, string>();
1802
1922
  /** Aggregate of the most recent stop's recorder closes; awaited by dispose() and
@@ -1860,11 +1980,13 @@ export class AgentSession {
1860
1980
  * generation path. Refresh via {@link AgentSession.setTitleSystemPrompt} when
1861
1981
  * the session cwd changes. */
1862
1982
  #titleSystemPrompt: string | undefined;
1983
+ #titleGenerationAbortController = new AbortController();
1863
1984
  #toolChoiceQueue = new ToolChoiceQueue();
1864
1985
 
1865
1986
  // Bash execution state
1866
1987
  #bashAbortControllers = new Set<AbortController>();
1867
- #pendingBashMessages: BashExecutionMessage[] = [];
1988
+ #pendingBashMessages: PendingBashMessage[] = [];
1989
+ #bashSessionTarget!: BashSessionTarget;
1868
1990
 
1869
1991
  // Python execution state
1870
1992
  #evalAbortControllers = new Set<AbortController>();
@@ -1898,9 +2020,17 @@ export class AgentSession {
1898
2020
  #providerSessionId: string | undefined;
1899
2021
  #freshProviderSessionId: string | undefined;
1900
2022
  #inheritedProviderPromptCacheKey: string | undefined;
2023
+ #autolearnCaptureAbortController: AbortController | undefined;
2024
+ #autolearnCaptureTask: Promise<void> | undefined;
1901
2025
  #isDisposed = false;
1902
2026
  // Extension system
1903
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;
1904
2034
  #turnIndex = 0;
1905
2035
  #messageEndPersistenceTail: Promise<void> = Promise.resolve();
1906
2036
  #pendingMessageEndPersistence = new Map<string, Promise<void>>();
@@ -1939,8 +2069,10 @@ export class AgentSession {
1939
2069
  #getLocalCalendarDate: () => string;
1940
2070
  #getMcpServerInstructions: (() => Map<string, string> | undefined) | undefined;
1941
2071
  #setActiveToolNames: ((names: Iterable<string>) => void) | undefined;
2072
+ #ensureWriteRegistered: (() => Promise<boolean>) | undefined;
1942
2073
  #disconnectOwnedMcpManager: (() => Promise<void>) | undefined;
1943
- #requestedToolNames: ReadonlySet<string> | undefined;
2074
+ #presentationPinnedToolNames: ReadonlySet<string> | undefined;
2075
+ #runtimeSelectedToolNames: ReadonlySet<string> | undefined;
1944
2076
  #baseSystemPrompt: string[];
1945
2077
  #baseSystemPromptBeforeMemoryPromotion: string[] | undefined;
1946
2078
  /**
@@ -1963,6 +2095,9 @@ export class AgentSession {
1963
2095
  #xdevRegistry: XdevRegistry | undefined;
1964
2096
  /** Names of discoverable tools currently mounted under `xd://` (dynamic mounts only, not built-in devices). */
1965
2097
  #mountedXdevToolNames = new Set<string>();
2098
+ /** Coalesced xd:// mount delta not yet announced to the model; delivered as a
2099
+ * hidden notice alongside the next prompt (see {@link #notifyXdevMountDelta}). */
2100
+ #pendingXdevMountDelta: { added: Set<string>; removed: Set<string> } | undefined;
1966
2101
 
1967
2102
  // TTSR manager for time-traveling stream rules
1968
2103
  #ttsrManager: TtsrManager | undefined = undefined;
@@ -2110,6 +2245,17 @@ export class AgentSession {
2110
2245
  * queue was consumed normally or a new turn already started. */
2111
2246
  #drainStrandedQueuedMessages(): void {
2112
2247
  if (this.#abortInProgress) return;
2248
+ // Session transitions (newSession/`/new`, compact, model-switch, session-switch,
2249
+ // dispose) call #disconnectFromAgent() BEFORE `await abort()`, so abort's own
2250
+ // finally lands here with no listener attached. Auto-resuming now would snapshot
2251
+ // the still-old context (the transition hasn't reached agent.reset() yet), start a
2252
+ // stale provider turn that races the reset, and — once reconnected — append its
2253
+ // output to the fresh session (issue #5800). A disconnected session never owns the
2254
+ // queue: the transition does. newSession/switchSession drop the queue (reset /
2255
+ // clearAllQueues), so nothing survives; compaction preserves it and re-drains itself
2256
+ // after #reconnectToAgent (see compact()'s finally); an explicit prompt flushes it
2257
+ // in every case.
2258
+ if (this.#unsubscribeAgent === undefined) return;
2113
2259
  // A concern steered into a resumed streaming run after a user interrupt can
2114
2260
  // strand at the turn tail (steered past the loop's final boundary poll). While
2115
2261
  // that interrupt's suppression is still in effect, reclaim such advisor steers
@@ -2431,12 +2577,17 @@ export class AgentSession {
2431
2577
  readPlan: url => this.#readPlanYoloFile(url),
2432
2578
  listPlanFiles: () => this.#listPlanYoloFiles(),
2433
2579
  });
2580
+ this.setPlanModeState(undefined);
2434
2581
  const previousTools = this.#planYoloPreviousTools;
2435
- if (previousTools) {
2436
- await this.setActiveToolsByName(previousTools);
2582
+ try {
2583
+ if (previousTools) {
2584
+ await this.setActiveToolsByName(previousTools);
2585
+ }
2586
+ } catch (error) {
2587
+ this.setPlanModeState(state);
2588
+ throw error;
2437
2589
  }
2438
2590
  this.setPlanProposalHandler(null);
2439
- this.setPlanModeState(undefined);
2440
2591
  this.#planYolo = undefined;
2441
2592
  this.#planYoloPreviousTools = undefined;
2442
2593
  await this.setModelTemporary(planYolo.target, planYolo.thinkingLevel, { ephemeral: true });
@@ -2494,6 +2645,11 @@ export class AgentSession {
2494
2645
  constructor(config: AgentSessionConfig) {
2495
2646
  this.agent = config.agent;
2496
2647
  this.sessionManager = config.sessionManager;
2648
+ this.#bashSessionTarget = {
2649
+ sessionId: this.sessionManager.getSessionId(),
2650
+ refs: 0,
2651
+ destination: { kind: "current", manager: this.sessionManager },
2652
+ };
2497
2653
  this.settings = config.settings;
2498
2654
  this.#autoApprove = config.autoApprove === true;
2499
2655
  // Power assertions are taken per turn (see #beginInFlight); nothing acquired here.
@@ -2544,7 +2700,8 @@ export class AgentSession {
2544
2700
  this.#toolRegistry = config.toolRegistry ?? new Map();
2545
2701
  this.#createVibeTools = config.createVibeTools;
2546
2702
  this.#builtInToolNames = new Set(config.builtInToolNames ?? []);
2547
- this.#requestedToolNames = config.requestedToolNames;
2703
+ this.#presentationPinnedToolNames = config.presentationPinnedToolNames;
2704
+ this.#ensureWriteRegistered = config.ensureWriteRegistered;
2548
2705
  this.#transformContext = config.transformContext ?? (messages => messages);
2549
2706
  this.#transformProviderContext = config.transformProviderContext;
2550
2707
  this.#sideStreamFn = config.sideStreamFn ?? streamSimple;
@@ -2596,7 +2753,18 @@ export class AgentSession {
2596
2753
  this.#advisorPrimaryTurnsCompleted++;
2597
2754
  if (this.#advisors.length > 0) {
2598
2755
  for (const a of this.#advisors) {
2599
- if (!a.runtime.disposed) a.runtime.onTurnEnd(messages, { willContinue: context?.willContinue });
2756
+ if (a.runtime.disposed) continue;
2757
+ try {
2758
+ a.runtime.onTurnEnd(messages, { willContinue: context?.willContinue });
2759
+ } catch (advisorErr) {
2760
+ // CRITICAL boundary: NOTHING an advisor does may abort the
2761
+ // primary agent's turn-end. A throwing advisor loses its
2762
+ // delta; the primary continues untouched.
2763
+ logger.warn("advisor onTurnEnd threw; delta dropped", {
2764
+ advisor: a.name,
2765
+ err: String(advisorErr),
2766
+ });
2767
+ }
2600
2768
  }
2601
2769
  const syncBacklog = this.settings.get("advisor.syncBacklog");
2602
2770
  if (syncBacklog !== "off") {
@@ -2805,6 +2973,12 @@ export class AgentSession {
2805
2973
  slug = candidate;
2806
2974
  usedSlugs.add(slug);
2807
2975
  }
2976
+ // Per-advisor toggle: skip disabled advisors but keep them in the
2977
+ // status map so they show `○` rather than disappearing.
2978
+ if (config.enabled === false) {
2979
+ this.#advisorStatuses.set(slug, { name: config.name, status: "paused" });
2980
+ continue;
2981
+ }
2808
2982
 
2809
2983
  // Resolve the advisor's model: an explicit `model` override wins; else the
2810
2984
  // `advisor` role chain. A model that fails to resolve skips just this advisor.
@@ -2815,6 +2989,7 @@ export class AgentSession {
2815
2989
  model = resolved.model;
2816
2990
  thinkingLevel = concreteThinkingLevel(resolved.thinkingLevel);
2817
2991
  if (!model) {
2992
+ this.#advisorStatuses.set(slug, { name: config.name, status: "no_model" });
2818
2993
  if (emitWarnings) {
2819
2994
  this.emitNotice("warning", `Advisor "${config.name}": no model matched "${config.model}"`, "advisor");
2820
2995
  }
@@ -2823,6 +2998,7 @@ export class AgentSession {
2823
2998
  } else {
2824
2999
  const sel = resolveAdvisorRoleSelection(this.settings, this.#modelRegistry.getAvailable());
2825
3000
  if (!sel) {
3001
+ this.#advisorStatuses.set(slug, { name: config.name, status: "no_model" });
2826
3002
  if (emitWarnings) {
2827
3003
  logger.debug("advisor enabled but no model assigned to the 'advisor' role; advisor inactive", {
2828
3004
  advisor: config.name,
@@ -2846,6 +3022,11 @@ export class AgentSession {
2846
3022
  const requestedLevel = thinkingLevel ?? ThinkingLevel.Medium;
2847
3023
  const resolvedLevel = resolveThinkingLevelForModel(model, requestedLevel);
2848
3024
  const advisorThinkingLevel: ThinkingLevel = resolvedLevel ?? ThinkingLevel.Inherit;
3025
+ // Record the status entry now (in roster order) so the Map's insertion
3026
+ // order matches the configured roster even when earlier advisors were
3027
+ // skipped as paused/no_model. The build loop overwrites this to "running"
3028
+ // without changing insertion order.
3029
+ this.#advisorStatuses.set(slug, { name: config.name, status: "running" });
2849
3030
  descriptors.push({
2850
3031
  config,
2851
3032
  name: config.name,
@@ -2881,6 +3062,11 @@ export class AgentSession {
2881
3062
  if (!this.#advisorEnabled) return false;
2882
3063
  if (this.#agentKind !== "main" && !this.settings.get("advisor.subagents")) return false;
2883
3064
 
3065
+ // Rebuild the status map from scratch so removed/renamed advisors don't
3066
+ // leave stale entries. #resolveAdvisorRuntimeDescriptors populates every
3067
+ // entry (`paused`/`no_model`/`running`) in roster order; the build loop
3068
+ // below confirms `running` for successfully built advisors.
3069
+ this.#advisorStatuses.clear();
2884
3070
  const descriptors = this.#resolveAdvisorRuntimeDescriptors(true);
2885
3071
 
2886
3072
  // Advisor service tier (`tier.advisor`): "none" (default) runs the advisor
@@ -2921,11 +3107,16 @@ export class AgentSession {
2921
3107
 
2922
3108
  const names = config.tools === undefined ? ADVISOR_DEFAULT_TOOL_NAMES : new Set(config.tools);
2923
3109
  const tools = (this.#advisorTools ?? []).filter(t => names.has(t.name));
3110
+ const advisorLoopTools: AgentTool<any>[] = [adviseTool, ...tools];
3111
+ const advisorToolMap = new Map<string, AgentTool>();
2924
3112
  const availableAdvisorToolNames = new Set<string>();
2925
- availableAdvisorToolNames.add(adviseTool.name);
2926
- for (const tool of tools) {
3113
+ for (const tool of advisorLoopTools) {
2927
3114
  availableAdvisorToolNames.add(tool.name);
2928
- if (tool.customWireName !== undefined) availableAdvisorToolNames.add(tool.customWireName);
3115
+ advisorToolMap.set(tool.name, tool);
3116
+ if (tool.customWireName !== undefined) {
3117
+ availableAdvisorToolNames.add(tool.customWireName);
3118
+ advisorToolMap.set(tool.customWireName, tool);
3119
+ }
2929
3120
  }
2930
3121
  let quarantinedAdvisorOutput: string | undefined;
2931
3122
  let currentAdvisorInput = "";
@@ -2965,17 +3156,36 @@ export class AgentSession {
2965
3156
  // Codex request identity remains UUID-shaped while local labels keep the
2966
3157
  // `-advisor` suffix.
2967
3158
  const advisorPromptCacheKey = this.agent.promptCacheKey ?? advisorProviderSessionId;
3159
+ // On the Cursor provider every tool runs server-side and is dispatched
3160
+ // back through `cursorExecHandlers`; without this bridge the advisor's
3161
+ // own tools (including the MCP `advise` tool) return `toolNotFound` and
3162
+ // no advice is ever routed (issue #5680). Mirrors the primary agent's
3163
+ // bridge (`sdk.ts`), scoped to this advisor's granted tool set.
3164
+ // Cursor's native `delete` frame removes files directly, bypassing the
3165
+ // tool map, so gate it on the advisor actually holding a file-mutating
3166
+ // tool. A default read-only advisor (advise/read/grep/glob) never gets
3167
+ // to delete workspace files it was never granted (issue #5680 review).
3168
+ const advisorCanMutateFiles = advisorToolMap.has("write") || advisorToolMap.has("edit");
3169
+ if (advisorCanMutateFiles) availableAdvisorToolNames.add("delete");
3170
+ const advisorCursorExecHandlers = new CursorExecHandlers({
3171
+ cwd: this.sessionManager.getCwd(),
3172
+ getCwd: () => this.sessionManager.getCwd(),
3173
+ tools: advisorToolMap,
3174
+ allowNativeDelete: advisorCanMutateFiles,
3175
+ });
2968
3176
  const advisorAgent = new Agent({
2969
3177
  initialState: {
2970
3178
  systemPrompt,
2971
3179
  model: advisorModel,
2972
3180
  thinkingLevel: toReasoningEffort(advisorThinkingLevel),
2973
- tools: [adviseTool, ...tools],
3181
+ tools: advisorLoopTools,
2974
3182
  },
2975
3183
  appendOnlyContext,
2976
3184
  sessionId: advisorProviderSessionId,
2977
3185
  promptCacheKey: advisorPromptCacheKey,
2978
3186
  providerSessionState: this.#providerSessionState,
3187
+ cursorExecHandlers: advisorCursorExecHandlers,
3188
+ cwdResolver: () => this.sessionManager.getCwd(),
2979
3189
  preferWebsockets: this.#preferWebsockets,
2980
3190
  getApiKey: requestModel => this.#modelRegistry.resolver(requestModel, advisorProviderSessionId),
2981
3191
  streamFn: this.#advisorStreamFn,
@@ -3046,33 +3256,30 @@ export class AgentSession {
3046
3256
  maintainContext: incomingTokens => this.#maintainAdvisorContext(advisorRef, incomingTokens),
3047
3257
  obfuscator: this.#obfuscator,
3048
3258
  beginAdvisorUpdate: () => advisorRef.emissionGuard.beginUpdate(),
3049
- onTurnError: async error => {
3050
- // Mirror the auth-gateway's usage-limit remedy: the in-stream a/b/c
3051
- // auth retry rotates through siblings within one request but never
3052
- // blocks the LAST failing credential, so without this the advisor
3053
- // re-picks the same exhausted account every retry. Usage limits
3054
- // only — other failures keep the plain retry/notify path (never
3055
- // suspect-mark a credential on a transient advisor error).
3056
- const message = error instanceof Error ? error.message : String(error);
3057
- if (!isUsageLimitOutcome(extractHttpStatusFromError(error), message)) return;
3058
- await this.#modelRegistry.authStorage.markUsageLimitReached(
3059
- advisorModel.provider,
3060
- advisorProviderSessionId,
3061
- {
3062
- retryAfterMs: extractRetryHint(undefined, message),
3063
- baseUrl: advisorModel.baseUrl,
3064
- modelId: advisorModel.id,
3065
- },
3066
- );
3259
+ onTurnError: (error, failedMessages) => this.#recoverAdvisorTurn(advisorRef, error, failedMessages),
3260
+ onTurnSuccess: async () => {
3261
+ const fallback = advisorRef.retryFallback;
3262
+ if (!advisorRef.retryFallbackPendingSuccess || !fallback) return;
3263
+ advisorRef.retryFallbackPendingSuccess = false;
3264
+ await this.#emitSessionEvent({
3265
+ type: "retry_fallback_succeeded",
3266
+ model: formatRetryFallbackSelector(advisorRef.agent.state.model, advisorRef.thinkingLevel),
3267
+ role: fallback.role,
3268
+ });
3067
3269
  },
3068
3270
  notifyFailure: error => {
3271
+ this.#advisorStatuses.set(slug, { name: advisorName, status: "error" });
3069
3272
  const message = error instanceof Error ? error.message : String(error);
3070
3273
  this.emitNotice(
3071
3274
  "warning",
3072
- `Advisor${slug ? ` "${advisorName}"` : ""} unavailable for ${formatModelString(advisorModel)}: ${message}`,
3275
+ `Advisor${slug ? ` "${advisorName}"` : ""} unavailable for ${formatModelString(advisorAgent.state.model)}: ${message}`,
3073
3276
  "advisor",
3074
3277
  );
3075
3278
  },
3279
+ notifyQuotaExhausted: () => {
3280
+ this.#advisorStatuses.set(slug, { name: advisorName, status: "quota_exhausted" });
3281
+ this.emitNotice("warning", `Advisor "${advisorName}" quota exhausted — pausing until reset.`, "advisor");
3282
+ },
3076
3283
  });
3077
3284
 
3078
3285
  const advisorRef: ActiveAdvisor = {
@@ -3086,10 +3293,13 @@ export class AgentSession {
3086
3293
  recorderClosed: Promise.resolve(),
3087
3294
  model: advisorModel,
3088
3295
  thinkingLevel: advisorThinkingLevel,
3296
+ providerSessionId: advisorProviderSessionId,
3297
+ retryFallbackPendingSuccess: false,
3089
3298
  signature,
3090
3299
  };
3091
3300
  this.#attachAdvisorRecorderFeed(advisorRef);
3092
3301
  if (seedToCurrent) runtime.seedTo(this.agent.state.messages.length);
3302
+ this.#advisorStatuses.set(slug, { name: advisorName, status: "running" });
3093
3303
  this.#advisors.push(advisorRef);
3094
3304
  }
3095
3305
 
@@ -3251,6 +3461,163 @@ export class AgentSession {
3251
3461
  });
3252
3462
  }
3253
3463
 
3464
+ /** Switch one advisor model while preserving its context and effort invariants. */
3465
+ #setAdvisorModel(advisor: ActiveAdvisor, model: Model, requestedThinkingLevel: ThinkingLevel): ThinkingLevel {
3466
+ const resolvedThinkingLevel = resolveThinkingLevelForModel(model, requestedThinkingLevel);
3467
+ const nextThinkingLevel = resolvedThinkingLevel ?? ThinkingLevel.Inherit;
3468
+ advisor.agent.setModel(model);
3469
+ advisor.agent.setThinkingLevel(toReasoningEffort(nextThinkingLevel));
3470
+ advisor.agent.setDisableReasoning(shouldDisableReasoning(nextThinkingLevel));
3471
+ advisor.agent.appendOnlyContext?.invalidateForModelChange();
3472
+ advisor.model = model;
3473
+ advisor.thinkingLevel = nextThinkingLevel;
3474
+ return nextThinkingLevel;
3475
+ }
3476
+
3477
+ /** Restore an advisor's configured primary once its fallback cooldown expires. */
3478
+ async #maybeRestoreAdvisorRetryFallbackPrimary(advisor: ActiveAdvisor): Promise<void> {
3479
+ const fallback = advisor.retryFallback;
3480
+ if (!fallback || this.#getRetryFallbackRevertPolicy() !== "cooldown-expiry") return;
3481
+
3482
+ const originalSelector = parseRetryFallbackSelector(fallback.originalSelector, this.#modelRegistry);
3483
+ if (!originalSelector) {
3484
+ advisor.retryFallback = undefined;
3485
+ advisor.retryFallbackPendingSuccess = false;
3486
+ return;
3487
+ }
3488
+ const currentSelector = formatRetryFallbackSelector(advisor.agent.state.model, advisor.thinkingLevel);
3489
+ if (currentSelector === originalSelector.raw) {
3490
+ if (!this.#isRetryFallbackSelectorSuppressed(originalSelector)) {
3491
+ advisor.retryFallback = undefined;
3492
+ advisor.retryFallbackPendingSuccess = false;
3493
+ }
3494
+ return;
3495
+ }
3496
+ if (this.#isRetryFallbackSelectorSuppressed(originalSelector)) return;
3497
+
3498
+ const resolvedPrimary = resolveModelOverride([originalSelector.raw], this.#modelRegistry, this.settings);
3499
+ const primaryModel =
3500
+ resolvedPrimary.model ?? this.#modelRegistry.find(originalSelector.provider, originalSelector.id);
3501
+ if (!primaryModel) return;
3502
+ const apiKey = await this.#modelRegistry.getApiKey(primaryModel, advisor.providerSessionId);
3503
+ if (!apiKey) return;
3504
+
3505
+ const thinkingToApply =
3506
+ advisor.thinkingLevel === fallback.lastAppliedThinkingLevel
3507
+ ? fallback.originalThinkingLevel
3508
+ : advisor.thinkingLevel;
3509
+ this.#setAdvisorModel(advisor, primaryModel, thinkingToApply);
3510
+ this.settings.getStorage()?.recordModelUsage(formatModelStringWithRouting(primaryModel));
3511
+ advisor.retryFallback = undefined;
3512
+ advisor.retryFallbackPendingSuccess = false;
3513
+ }
3514
+
3515
+ /**
3516
+ * Apply the advisor's configured provider-failure fallback chain after
3517
+ * same-provider credential rotation has no usable sibling.
3518
+ */
3519
+ async #recoverAdvisorTurn(
3520
+ advisor: ActiveAdvisor,
3521
+ error: unknown,
3522
+ failedMessages: readonly AgentMessage[],
3523
+ ): Promise<boolean> {
3524
+ if (error instanceof AdvisorOutputQuarantinedError) return false;
3525
+
3526
+ const failedMessage = failedMessages.findLast(
3527
+ (message): message is AssistantMessage => message.role === "assistant",
3528
+ );
3529
+ if (failedMessage?.stopReason !== "error") {
3530
+ // Stream setup can reject before any assistant turn is recorded (e.g.
3531
+ // an HTTP 429 thrown from prompt()); classify the raw error so a
3532
+ // structural usage limit still marks the exhausted credential.
3533
+ const message = error instanceof Error ? error.message : String(error);
3534
+ if (!AIError.isUsageLimit(error) && !isUsageLimitOutcome(extractHttpStatusFromError(error), message)) {
3535
+ return false;
3536
+ }
3537
+ const currentModel = advisor.agent.state.model;
3538
+ const outcome = await this.#modelRegistry.authStorage.markUsageLimitReached(
3539
+ currentModel.provider,
3540
+ advisor.providerSessionId,
3541
+ {
3542
+ retryAfterMs: extractRetryHint(undefined, message),
3543
+ baseUrl: currentModel.baseUrl,
3544
+ modelId: currentModel.id,
3545
+ },
3546
+ );
3547
+ return outcome.switched;
3548
+ }
3549
+ if (failedMessage.content.some(block => block.type === "toolCall")) return false;
3550
+
3551
+ const currentModel = advisor.agent.state.model;
3552
+ const message = failedMessage.errorMessage ?? (error instanceof Error ? error.message : String(error));
3553
+ const errorId = AIError.classifyMessage({
3554
+ api: currentModel.api,
3555
+ errorId: failedMessage.errorId,
3556
+ errorMessage: message,
3557
+ errorStatus: failedMessage.errorStatus,
3558
+ });
3559
+ if (AIError.is(errorId, AIError.Flag.Abort) || AIError.is(errorId, AIError.Flag.UserInterrupt)) return false;
3560
+ if (AIError.isContextOverflow(failedMessage, currentModel.contextWindow ?? 0)) return false;
3561
+
3562
+ const currentSelector = formatRetryFallbackSelector(currentModel, advisor.thinkingLevel);
3563
+
3564
+ const retryAfterMs = extractRetryHint(undefined, message);
3565
+ if (
3566
+ AIError.is(errorId, AIError.Flag.UsageLimit) ||
3567
+ isUsageLimitOutcome(extractHttpStatusFromError(error), message)
3568
+ ) {
3569
+ const outcome = await this.#modelRegistry.authStorage.markUsageLimitReached(
3570
+ currentModel.provider,
3571
+ advisor.providerSessionId,
3572
+ {
3573
+ retryAfterMs,
3574
+ baseUrl: currentModel.baseUrl,
3575
+ modelId: currentModel.id,
3576
+ },
3577
+ );
3578
+ if (outcome.switched) return true;
3579
+ }
3580
+
3581
+ const retrySettings = this.settings.getGroup("retry");
3582
+ if (!retrySettings.enabled || !retrySettings.modelFallback) return false;
3583
+ const role = advisor.retryFallback?.role ?? this.#resolveRetryFallbackRole(currentSelector, currentModel);
3584
+ if (!role || this.#findRetryFallbackCandidates(role, currentSelector, currentModel).length === 0) return false;
3585
+
3586
+ this.#noteRetryFallbackCooldown(currentSelector, retryAfterMs, message);
3587
+ for (const selector of this.#findRetryFallbackCandidates(role, currentSelector, currentModel)) {
3588
+ if (this.#isRetryFallbackSelectorSuppressed(selector)) continue;
3589
+ const resolved = resolveModelOverride([selector.raw], this.#modelRegistry, this.settings);
3590
+ const candidate = resolved.model ?? this.#modelRegistry.find(selector.provider, selector.id);
3591
+ if (!candidate || modelsAreEqual(candidate, currentModel)) continue;
3592
+ const apiKey = await this.#modelRegistry.getApiKey(candidate, advisor.providerSessionId);
3593
+ if (!apiKey) continue;
3594
+
3595
+ const originalThinkingLevel = advisor.thinkingLevel;
3596
+ const requestedThinkingLevel = selector.thinkingLevel ?? originalThinkingLevel;
3597
+ const nextThinkingLevel = this.#setAdvisorModel(advisor, candidate, requestedThinkingLevel);
3598
+ if (advisor.retryFallback) {
3599
+ advisor.retryFallback.lastAppliedThinkingLevel = nextThinkingLevel;
3600
+ } else {
3601
+ advisor.retryFallback = {
3602
+ role,
3603
+ originalSelector: currentSelector,
3604
+ originalThinkingLevel,
3605
+ lastAppliedThinkingLevel: nextThinkingLevel,
3606
+ };
3607
+ }
3608
+ advisor.retryFallbackPendingSuccess = true;
3609
+ this.settings.getStorage()?.recordModelUsage(formatModelStringWithRouting(candidate));
3610
+ await this.#emitSessionEvent({
3611
+ type: "retry_fallback_applied",
3612
+ from: currentSelector,
3613
+ to: selector.raw,
3614
+ role,
3615
+ });
3616
+ return true;
3617
+ }
3618
+ return false;
3619
+ }
3620
+
3254
3621
  async #promoteAdvisorContextModel(advisor: ActiveAdvisor, currentModel: Model): Promise<boolean> {
3255
3622
  const promotionSettings = this.settings.getGroup("contextPromotion");
3256
3623
  if (!promotionSettings.enabled) return false;
@@ -3263,10 +3630,7 @@ export class AgentSession {
3263
3630
  // keeps its suffix across a promotion); only the model changes.
3264
3631
  const advisorThinkingLevel = advisor.thinkingLevel;
3265
3632
  try {
3266
- advisor.agent.setModel(targetModel);
3267
- advisor.agent.setThinkingLevel(toReasoningEffort(advisorThinkingLevel));
3268
- advisor.agent.setDisableReasoning(shouldDisableReasoning(advisorThinkingLevel));
3269
- advisor.agent.appendOnlyContext?.invalidateForModelChange();
3633
+ this.#setAdvisorModel(advisor, targetModel, advisorThinkingLevel);
3270
3634
  logger.debug("Advisor context promotion switched model on overflow", {
3271
3635
  advisor: advisor.name,
3272
3636
  from: `${currentModel.provider}/${currentModel.id}`,
@@ -3285,6 +3649,7 @@ export class AgentSession {
3285
3649
  }
3286
3650
 
3287
3651
  async #maintainAdvisorContext(advisor: ActiveAdvisor, incomingTokens: number): Promise<boolean> {
3652
+ await this.#maybeRestoreAdvisorRetryFallbackPrimary(advisor);
3288
3653
  const agent = advisor.agent;
3289
3654
 
3290
3655
  const compactionSettings = this.settings.getGroup("compaction");
@@ -3296,10 +3661,23 @@ export class AgentSession {
3296
3661
  if (contextWindow <= 0) return false;
3297
3662
 
3298
3663
  const messages = agent.state.messages;
3299
- let contextTokens = incomingTokens;
3664
+ const estimateOptions = { excludeEncryptedReasoning: true } as const;
3665
+ let storedConversationTokens = 0;
3300
3666
  for (const message of messages) {
3301
- contextTokens += estimateTokens(message);
3302
- }
3667
+ storedConversationTokens += estimateTokens(message, estimateOptions);
3668
+ }
3669
+ // Provider usage (including cache reads and generated output) is the
3670
+ // trustworthy anchor for accumulated context. Add only the trailing incoming
3671
+ // delta to that arm. Floor it by a full local estimate — fixed advisor system
3672
+ // prompt, tool schemas, stored messages, and incoming delta — so provider
3673
+ // under-reporting or payload transforms cannot suppress maintenance.
3674
+ const providerContextTokens = this.#estimateAdvisorContextTokens(messages) + incomingTokens;
3675
+ const localContextTokens =
3676
+ countTokens(agent.state.systemPrompt) +
3677
+ estimateToolSchemaTokens(agent.state.tools) +
3678
+ storedConversationTokens +
3679
+ incomingTokens;
3680
+ const contextTokens = compactionContextTokens(providerContextTokens, localContextTokens);
3303
3681
 
3304
3682
  if (!shouldCompact(contextTokens, contextWindow, compactionSettings)) {
3305
3683
  return false;
@@ -3323,6 +3701,7 @@ export class AgentSession {
3323
3701
  const timestamp = String(message.timestamp || Date.now());
3324
3702
 
3325
3703
  if (message.role === "compactionSummary") {
3704
+ const advisorSummary = message as AdvisorCompactionSummaryMessage;
3326
3705
  return {
3327
3706
  type: "compaction",
3328
3707
  id,
@@ -3330,9 +3709,7 @@ export class AgentSession {
3330
3709
  timestamp,
3331
3710
  summary: message.summary,
3332
3711
  shortSummary: message.shortSummary,
3333
- firstKeptEntryId:
3334
- (message as CompactionSummaryMessage & { firstKeptEntryId?: string }).firstKeptEntryId ||
3335
- `msg-${i + 1}`,
3712
+ firstKeptEntryId: advisorSummary.firstKeptEntryId || `msg-${i + 1}`,
3336
3713
  tokensBefore: message.tokensBefore,
3337
3714
  } satisfies CompactionEntry;
3338
3715
  }
@@ -3426,11 +3803,15 @@ export class AgentSession {
3426
3803
  const firstKeptEntryId = compactResult.firstKeptEntryId;
3427
3804
  const tokensBefore = compactResult.tokensBefore;
3428
3805
 
3429
- // Rebuild messages with the compaction summary
3806
+ // The retained messages still carry provider usage from before this
3807
+ // compaction. Record their exact array boundary on the in-memory summary so
3808
+ // only assistants appended afterward can become the next usage anchor.
3809
+ const advisorUsageAnchorStartIndex = preparation.recentMessages.length + 1;
3430
3810
  const summaryMessage = {
3431
3811
  ...createCompactionSummaryMessage(summary, tokensBefore, new Date().toISOString(), shortSummary),
3432
3812
  firstKeptEntryId,
3433
- } as CompactionSummaryMessage & { firstKeptEntryId?: string };
3813
+ advisorUsageAnchorStartIndex,
3814
+ } satisfies AdvisorCompactionSummaryMessage;
3434
3815
 
3435
3816
  agent.replaceMessages([summaryMessage, ...preparation.recentMessages]);
3436
3817
  return false;
@@ -3986,7 +4367,7 @@ export class AgentSession {
3986
4367
  }
3987
4368
  const skipPersistedRewindResult =
3988
4369
  message.role === "toolResult" &&
3989
- message.toolName === "rewind" &&
4370
+ semanticToolResult(message.toolName, message)?.toolName === "rewind" &&
3990
4371
  this.#rewoundToolResultIds.delete(message.toolCallId);
3991
4372
  if (!skipPersistedRewindResult) {
3992
4373
  this.#appendSessionMessage(message);
@@ -4357,29 +4738,28 @@ export class AgentSession {
4357
4738
  }
4358
4739
  }
4359
4740
  if (event.message.role === "toolResult") {
4360
- const { toolName, toolCallId, details, isError, content } = event.message as {
4361
- toolCallId?: string;
4362
- toolName?: string;
4363
- details?: { op?: string; path?: string; phases?: TodoPhase[]; report?: string; startedAt?: string };
4364
- isError?: boolean;
4365
- content?: Array<TextContent | ImageContent>;
4366
- };
4741
+ const { toolName, toolCallId, isError, content } = event.message;
4742
+ const details = isRecord(event.message.details) ? event.message.details : undefined;
4743
+ const semanticResult = semanticToolResult(toolName, event.message);
4744
+ const semanticDetails = isRecord(semanticResult?.details) ? semanticResult.details : undefined;
4367
4745
  // A tool actually ran. Clear the post-reminder suppression: the agent did
4368
4746
  // productive work in response to the prior nudge, so the next text-only stop
4369
4747
  // is allowed to escalate to the next reminder if todos remain incomplete.
4370
4748
  this.#todoReminderAwaitingProgress = false;
4371
4749
  // Invalidate streaming edit cache when edit tool completes to prevent stale data
4372
- if (toolName === "edit" && details?.path) {
4373
- this.#invalidateFileCacheForPath(details.path);
4750
+ const editedPath = details ? getStringProperty(details, "path") : undefined;
4751
+ if (toolName === "edit" && editedPath) {
4752
+ this.#invalidateFileCacheForPath(editedPath);
4374
4753
  }
4375
- if (toolName === "todo" && !isError && Array.isArray(details?.phases)) {
4376
- this.setTodoPhases(details.phases);
4754
+ const phases = details?.phases;
4755
+ if (toolName === "todo" && !isError && details && Array.isArray(phases) && phases.every(isTodoPhase)) {
4756
+ this.setTodoPhases(phases);
4377
4757
  if (this.#isTodoInitResult(details, toolCallId)) {
4378
4758
  this.#scheduleReplanTitleRefresh();
4379
4759
  }
4380
4760
  }
4381
4761
  if (toolName === "todo" && isError) {
4382
- const errorText = content?.find(part => part.type === "text")?.text;
4762
+ const errorText = content.find(part => part.type === "text")?.text;
4383
4763
  const reminderText = [
4384
4764
  "<system-reminder>",
4385
4765
  "todo failed, so todo progress is not visible to the user.",
@@ -4397,18 +4777,19 @@ export class AgentSession {
4397
4777
  { deliverAs: "nextTurn" },
4398
4778
  );
4399
4779
  }
4400
- if (toolName === "checkpoint" && !isError) {
4780
+ if (semanticResult?.toolName === "checkpoint" && !isError) {
4401
4781
  const checkpointEntryId = this.sessionManager.getEntries().at(-1)?.id ?? null;
4402
4782
  this.#checkpointState = {
4403
4783
  checkpointMessageCount: this.agent.state.messages.length,
4404
4784
  checkpointEntryId,
4405
- startedAt: details?.startedAt ?? new Date().toISOString(),
4785
+ startedAt:
4786
+ (semanticDetails && stringProperty(semanticDetails, "startedAt")) ?? new Date().toISOString(),
4406
4787
  };
4407
4788
  this.#pendingRewindReport = undefined;
4408
4789
  this.#lastCompletedRewind = undefined;
4409
4790
  }
4410
- if (toolName === "rewind" && !isError && this.#checkpointState) {
4411
- const detailReport = typeof details?.report === "string" ? details.report.trim() : "";
4791
+ if (semanticResult?.toolName === "rewind" && !isError && this.#checkpointState) {
4792
+ const detailReport = semanticDetails ? (stringProperty(semanticDetails, "report")?.trim() ?? "") : "";
4412
4793
  const textReport = content?.find(part => part.type === "text")?.text?.trim() ?? "";
4413
4794
  const report = detailReport || textReport;
4414
4795
  if (report.length > 0) {
@@ -4421,8 +4802,11 @@ export class AgentSession {
4421
4802
  // Check auto-retry and auto-compaction after agent completes
4422
4803
  if (event.type === "agent_end") {
4423
4804
  const settledMessages = this.agent.state.messages;
4424
- const emitAgentEndNotification = async () => {
4425
- await this.#emitAgentEndNotification(settledMessages);
4805
+ // TTSR retry work runs concurrently and clears the live flag before
4806
+ // maintenance can emit agent_end, so preserve the state at settle entry.
4807
+ const ttsrAbortPendingAtAgentEnd = this.#ttsrAbortPending;
4808
+ const emitAgentEndNotification = async (options?: { willContinue?: boolean }) => {
4809
+ await this.#emitAgentEndNotification(settledMessages, options);
4426
4810
  };
4427
4811
  const usage = this.getSessionStats().tokens;
4428
4812
  await this.#goalRuntime.onAgentEnd({
@@ -4524,7 +4908,7 @@ export class AgentSession {
4524
4908
  // active-goal threshold pre-empt below.
4525
4909
  if (await this.#handleEmptyAssistantStop(msg)) {
4526
4910
  maintenanceRoute("empty-stop-handled");
4527
- await emitAgentEndNotification();
4911
+ await emitAgentEndNotification({ willContinue: true });
4528
4912
  return;
4529
4913
  }
4530
4914
 
@@ -4547,30 +4931,34 @@ export class AgentSession {
4547
4931
  automaticContinuationBlocked: compactionResult.automaticContinuationBlocked === true,
4548
4932
  });
4549
4933
  this.#resolveRetry();
4550
- await emitAgentEndNotification();
4934
+ await emitAgentEndNotification(
4935
+ compactionResult.continuationScheduled ? { willContinue: true } : undefined,
4936
+ );
4551
4937
  return;
4552
4938
  }
4553
4939
  }
4554
4940
 
4555
4941
  if (await this.#handleUnexpectedAssistantStop(msg)) {
4556
4942
  maintenanceRoute("unexpected-stop-handled");
4557
- await emitAgentEndNotification();
4943
+ await emitAgentEndNotification({ willContinue: true });
4558
4944
  return;
4559
4945
  }
4560
4946
 
4561
4947
  if (this.#isRetryableReasonlessAbort(msg)) {
4562
4948
  const didRetry = await this.#handleRetryableError(msg, { allowModelFallback: false });
4563
4949
  if (didRetry) {
4564
- await emitAgentEndNotification();
4950
+ await emitAgentEndNotification({ willContinue: true });
4565
4951
  return;
4566
4952
  }
4567
4953
  }
4568
4954
 
4569
- // A deliberate abort should settle the current turn, not trigger queued continuations.
4955
+ // A deliberate abort should settle the current turn, not trigger queued
4956
+ // continuations — except TTSR self-repair, which already scheduled a
4957
+ // hidden retry while #ttsrAbortPending is still true.
4570
4958
  if (msg.stopReason === "aborted") {
4571
4959
  this.#resolveRetry();
4572
4960
  this.#resetSessionStopContinuationState();
4573
- await emitAgentEndNotification();
4961
+ await emitAgentEndNotification(ttsrAbortPendingAtAgentEnd ? { willContinue: true } : undefined);
4574
4962
  return;
4575
4963
  }
4576
4964
  // Fireworks Fast variants degrade to their base model on a failed turn —
@@ -4579,14 +4967,14 @@ export class AgentSession {
4579
4967
  if (this.#isFireworksFastFallbackEligible(msg)) {
4580
4968
  const didRetry = await this.#handleRetryableError(msg, { fireworksFastFallback: true });
4581
4969
  if (didRetry) {
4582
- await emitAgentEndNotification();
4970
+ await emitAgentEndNotification({ willContinue: true });
4583
4971
  return;
4584
4972
  }
4585
4973
  }
4586
4974
  if (this.#isRetryableError(msg)) {
4587
4975
  const didRetry = await this.#handleRetryableError(msg);
4588
4976
  if (didRetry) {
4589
- await emitAgentEndNotification();
4977
+ await emitAgentEndNotification({ willContinue: true });
4590
4978
  return;
4591
4979
  }
4592
4980
  } else if (this.#isHardErrorFallbackEligible(msg)) {
@@ -4597,7 +4985,7 @@ export class AgentSession {
4597
4985
  // backoff-retry of the failing model) when no switch happens.
4598
4986
  const didRetry = await this.#handleRetryableError(msg, { hardErrorFallback: true });
4599
4987
  if (didRetry) {
4600
- await emitAgentEndNotification();
4988
+ await emitAgentEndNotification({ willContinue: true });
4601
4989
  return;
4602
4990
  }
4603
4991
  }
@@ -4636,22 +5024,22 @@ export class AgentSession {
4636
5024
  compactionResult.continuationScheduled ||
4637
5025
  compactionResult.automaticContinuationBlocked
4638
5026
  ) {
4639
- await emitAgentEndNotification();
5027
+ await emitAgentEndNotification(compactionResult.continuationScheduled ? { willContinue: true } : undefined);
4640
5028
  return;
4641
5029
  }
4642
5030
  if (msg.stopReason !== "error") {
4643
5031
  if (this.#enforceRewindBeforeYield()) {
4644
- await emitAgentEndNotification();
5032
+ await emitAgentEndNotification({ willContinue: true });
4645
5033
  return;
4646
5034
  }
4647
5035
  const planModeContinuationScheduled = await this.#enforcePlanModeDecisionAtSettle();
4648
5036
  if (planModeContinuationScheduled) {
4649
- await emitAgentEndNotification();
5037
+ await emitAgentEndNotification({ willContinue: true });
4650
5038
  return;
4651
5039
  }
4652
5040
  const todoContinuationScheduled = await this.#checkTodoCompletion(msg);
4653
5041
  if (todoContinuationScheduled) {
4654
- await emitAgentEndNotification();
5042
+ await emitAgentEndNotification({ willContinue: true });
4655
5043
  return;
4656
5044
  }
4657
5045
  }
@@ -4661,11 +5049,11 @@ export class AgentSession {
4661
5049
  // the session is fully idle (the todo reminder above defers the same
4662
5050
  // way inside #checkTodoCompletion).
4663
5051
  if (this.#hasPendingAsyncWake()) {
4664
- await emitAgentEndNotification();
5052
+ await emitAgentEndNotification({ willContinue: true });
4665
5053
  return;
4666
5054
  }
4667
- await this.#emitSessionStopEvent(settledMessages, msg);
4668
- await emitAgentEndNotification();
5055
+ const sessionStopWillContinue = await this.#emitSessionStopEvent(settledMessages, msg);
5056
+ await emitAgentEndNotification(sessionStopWillContinue ? { willContinue: true } : undefined);
4669
5057
  }
4670
5058
  };
4671
5059
 
@@ -4795,7 +5183,28 @@ export class AgentSession {
4795
5183
  );
4796
5184
  }
4797
5185
 
4798
- #scheduleAutoContinuePrompt(generation: number): void {
5186
+ #scheduleCompactionContinuation(options: {
5187
+ generation: number;
5188
+ autoContinue: boolean;
5189
+ terminalTextAnswer: boolean;
5190
+ suppressContinuation: boolean;
5191
+ }): boolean {
5192
+ if (options.suppressContinuation) return false;
5193
+ if (this.agent.hasQueuedMessages()) {
5194
+ this.#scheduleAgentContinue({
5195
+ delayMs: 100,
5196
+ generation: options.generation,
5197
+ shouldContinue: () => this.agent.hasQueuedMessages(),
5198
+ });
5199
+ return true;
5200
+ }
5201
+ if (!options.autoContinue) return false;
5202
+ const activeGoal = this.#goalModeState?.enabled === true && this.#goalModeState.goal.status === "active";
5203
+ if (options.terminalTextAnswer && !activeGoal) return false;
5204
+ return this.#scheduleAutoContinuePrompt(options.generation);
5205
+ }
5206
+
5207
+ #scheduleAutoContinuePrompt(generation: number): boolean {
4799
5208
  const continuePrompt = async () => {
4800
5209
  // Compaction summarizes away the first-message eager preludes, so re-assert the
4801
5210
  // delegate-via-tasks / phased-todo reminders on this auto-resumed turn. This runs
@@ -4820,10 +5229,18 @@ export class AgentSession {
4820
5229
  async signal => {
4821
5230
  await Promise.resolve();
4822
5231
  if (signal.aborted) return;
5232
+ if (this.agent.hasQueuedMessages()) {
5233
+ this.#scheduleAgentContinue({
5234
+ generation,
5235
+ shouldContinue: () => this.agent.hasQueuedMessages(),
5236
+ });
5237
+ return;
5238
+ }
4823
5239
  await continuePrompt();
4824
5240
  },
4825
5241
  { generation },
4826
5242
  );
5243
+ return true;
4827
5244
  }
4828
5245
 
4829
5246
  async #cancelPostPromptTasks(): Promise<void> {
@@ -5884,15 +6301,22 @@ export class AgentSession {
5884
6301
  return undefined;
5885
6302
  }
5886
6303
 
5887
- async #emitAgentEndNotification(messages: AgentMessage[]): Promise<void> {
5888
- await this.#extensionRunner?.emit({ type: "agent_end", messages });
6304
+ async #emitAgentEndNotification(messages: AgentMessage[], options?: { willContinue?: boolean }): Promise<void> {
6305
+ await this.#extensionRunner?.emit({
6306
+ type: "agent_end",
6307
+ messages,
6308
+ willContinue: options?.willContinue,
6309
+ });
5889
6310
  }
5890
6311
 
6312
+ /** @returns true when a hidden session_stop continuation turn was scheduled. */
5891
6313
  async #emitSessionStopEvent(
5892
6314
  messages: AgentMessage[],
5893
6315
  lastAssistantMessage = this.getLastAssistantMessage(),
5894
- ): Promise<void> {
5895
- if (this.#agentKind === "sub" || !this.#extensionRunner?.hasHandlers("session_stop")) return;
6316
+ ): Promise<boolean> {
6317
+ if (this.#agentKind === "sub" || !this.#extensionRunner?.hasHandlers("session_stop")) {
6318
+ return false;
6319
+ }
5896
6320
  const generation = this.#promptGeneration;
5897
6321
  const result = await this.#extensionRunner.emitSessionStop({
5898
6322
  messages,
@@ -5904,12 +6328,12 @@ export class AgentSession {
5904
6328
  });
5905
6329
  if (this.#promptGeneration !== generation || this.#abortInProgress || this.#isDisposed) {
5906
6330
  this.#resetSessionStopContinuationState();
5907
- return;
6331
+ return false;
5908
6332
  }
5909
6333
  const additionalContext = this.#sessionStopContinuationContext(result);
5910
6334
  if (!additionalContext) {
5911
6335
  this.#resetSessionStopContinuationState();
5912
- return;
6336
+ return false;
5913
6337
  }
5914
6338
  if (this.#sessionStopContinuationCount >= SESSION_STOP_CONTINUATION_CAP) {
5915
6339
  logger.warn("session_stop continuation cap reached", {
@@ -5917,7 +6341,7 @@ export class AgentSession {
5917
6341
  cap: SESSION_STOP_CONTINUATION_CAP,
5918
6342
  });
5919
6343
  this.#resetSessionStopContinuationState();
5920
- return;
6344
+ return false;
5921
6345
  }
5922
6346
  this.#sessionStopContinuationCount++;
5923
6347
  this.#sessionStopHookActive = true;
@@ -5932,6 +6356,7 @@ export class AgentSession {
5932
6356
  },
5933
6357
  true,
5934
6358
  );
6359
+ return true;
5935
6360
  }
5936
6361
 
5937
6362
  /** Emit extension events based on session events */
@@ -6204,6 +6629,43 @@ export class AgentSession {
6204
6629
  await this.refreshBaseSystemPrompt();
6205
6630
  }
6206
6631
  }
6632
+ /** Run one abortable auto-learn capture outside the primary agent loop. */
6633
+ async runAutolearnCapture(capture: (signal: AbortSignal) => Promise<void>): Promise<void> {
6634
+ if (this.#autolearnCaptureTask || this.#isDisposed) return;
6635
+ const controller = new AbortController();
6636
+ this.#autolearnCaptureAbortController = controller;
6637
+ const task = (async () => {
6638
+ try {
6639
+ await capture(controller.signal);
6640
+ } catch (error) {
6641
+ if (!controller.signal.aborted) throw error;
6642
+ } finally {
6643
+ if (this.#autolearnCaptureAbortController === controller) {
6644
+ this.#autolearnCaptureAbortController = undefined;
6645
+ }
6646
+ }
6647
+ })();
6648
+ this.#autolearnCaptureTask = task;
6649
+ try {
6650
+ await task;
6651
+ } finally {
6652
+ if (this.#autolearnCaptureTask === task) this.#autolearnCaptureTask = undefined;
6653
+ }
6654
+ }
6655
+
6656
+ #abortAutolearnCapture(): void {
6657
+ this.#autolearnCaptureAbortController?.abort();
6658
+ }
6659
+
6660
+ async #drainAutolearnCapture(): Promise<void> {
6661
+ const task = this.#autolearnCaptureTask;
6662
+ if (!task) return;
6663
+ try {
6664
+ await withTimeout(task, 3_000, "Timed out draining auto-learn capture during dispose");
6665
+ } catch (error) {
6666
+ logger.warn("Auto-learn capture did not settle during dispose", { error: String(error) });
6667
+ }
6668
+ }
6207
6669
 
6208
6670
  /** True once dispose() has begun; deferred background work (e.g. the deferred
6209
6671
  * MCP discovery task in sdk.ts) must not touch the session past this point. */
@@ -6226,6 +6688,8 @@ export class AgentSession {
6226
6688
  */
6227
6689
  beginDispose(): void {
6228
6690
  this.#isDisposed = true;
6691
+ this.#titleGenerationAbortController.abort();
6692
+ this.#abortAutolearnCapture();
6229
6693
  this.#flushPendingIrcAsides();
6230
6694
  this.yieldQueue.clear();
6231
6695
  this.agent.setAsideMessageProvider(undefined);
@@ -6262,6 +6726,12 @@ export class AgentSession {
6262
6726
  } catch (error) {
6263
6727
  logger.warn("Failed to emit session_shutdown event", { error: String(error) });
6264
6728
  }
6729
+ // Clear any timers extensions scheduled via `ctx.setInterval`/`ctx.setTimeout`
6730
+ // so their background work does not outlive the session (issue #5664).
6731
+ // Optional-called: hosts and tests may inject partial runner facades that
6732
+ // implement only the dispatch surface.
6733
+ this.#extensionRunner?.clearManagedTimers?.();
6734
+ this.#fallbackExtensionTimers?.clearAll();
6265
6735
  // Abort post-prompt work so the drain below can complete. Without this, a
6266
6736
  // deferred-handoff task that has already advanced into
6267
6737
  // `await this.handoff(...) → generateHandoff(...)` keeps awaiting a live LLM stream
@@ -6279,6 +6749,7 @@ export class AgentSession {
6279
6749
  const postPromptDrain = this.#cancelPostPromptTasks();
6280
6750
  this.agent.abort();
6281
6751
  await postPromptDrain;
6752
+ await this.#drainAutolearnCapture();
6282
6753
  // Cancel jobs this agent registered so a subagent's teardown doesn't
6283
6754
  // leak its background bash/task work into the parent's manager. Only
6284
6755
  // the session that owns the manager goes on to dispose it (which itself
@@ -6753,82 +7224,159 @@ export class AgentSession {
6753
7224
 
6754
7225
  async #applyActiveToolsByName(toolNames: string[]): Promise<void> {
6755
7226
  toolNames = normalizeToolNames(toolNames);
7227
+ const selectedTools = toolNames.flatMap(name => {
7228
+ const tool = this.#toolRegistry.get(name);
7229
+ return tool ? [{ name, tool }] : [];
7230
+ });
7231
+ const xdevReadAvailable = this.#builtInToolNames.has("read") && selectedTools.some(({ name }) => name === "read");
7232
+ const isPresentationPinned = (name: string): boolean =>
7233
+ this.#presentationPinnedToolNames?.has(name) === true || this.#runtimeSelectedToolNames?.has(name) === true;
7234
+ const mountCandidates = selectedTools.filter(
7235
+ ({ name, tool }) =>
7236
+ this.#xdevRegistry !== undefined &&
7237
+ xdevReadAvailable &&
7238
+ !isPresentationPinned(name) &&
7239
+ isMountableUnderXdev(tool),
7240
+ );
7241
+
7242
+ let builtInWriteAvailable = this.#builtInToolNames.has("write");
7243
+ if (mountCandidates.length > 0 && !builtInWriteAvailable) {
7244
+ builtInWriteAvailable = (await this.#ensureWriteRegistered?.()) === true;
7245
+ if (builtInWriteAvailable) this.#builtInToolNames.add("write");
7246
+ }
7247
+ const mountNames = builtInWriteAvailable ? new Set(mountCandidates.map(({ name }) => name)) : new Set<string>();
6756
7248
  const tools: AgentTool[] = [];
6757
7249
  const validToolNames: string[] = [];
6758
7250
  const mountedTools: AgentTool[] = [];
6759
- for (const name of toolNames) {
6760
- const tool = this.#toolRegistry.get(name);
6761
- if (!tool) continue;
6762
- // Discoverable tools are presented as `xd://` devices (kept out of the
6763
- // top-level schema) when the transport is active; everything else stays
6764
- // top-level. `loadMode` decides presentation only — selection is upstream.
6765
- if (this.#xdevRegistry && isMountableUnderXdev(tool)) {
6766
- mountedTools.push(tool);
7251
+ for (const { name, tool } of selectedTools) {
7252
+ if (mountNames.has(name)) {
7253
+ mountedTools.push(this.#wrapToolForAcpPermission(tool));
6767
7254
  } else {
6768
7255
  tools.push(this.#wrapToolForAcpPermission(tool));
6769
7256
  validToolNames.push(name);
6770
7257
  }
6771
7258
  }
6772
- // Reconcile the dynamic `xd://` mounts: newly-active discoverable tools are
6773
- // mounted, deactivated ones dropped (built-in devices are preserved). A
6774
- // removed or disconnected tool must not stay callable through a stale device.
7259
+
7260
+ const pinnedWrite = isPresentationPinned("write");
7261
+ const activeDeferrableTool = tools.some(tool => tool.deferrable === true);
7262
+ const transportNeeded = mountedTools.length > 0 || activeDeferrableTool || this.#planModeState?.enabled === true;
7263
+ if (transportNeeded && !builtInWriteAvailable) {
7264
+ builtInWriteAvailable = (await this.#ensureWriteRegistered?.()) === true;
7265
+ if (builtInWriteAvailable) this.#builtInToolNames.add("write");
7266
+ }
7267
+ if (transportNeeded && builtInWriteAvailable) {
7268
+ const write = this.#toolRegistry.get("write");
7269
+ if (write && !validToolNames.includes("write")) {
7270
+ tools.push(this.#wrapToolForAcpPermission(write));
7271
+ validToolNames.push("write");
7272
+ }
7273
+ } else if (
7274
+ !pinnedWrite &&
7275
+ (this.#presentationPinnedToolNames !== undefined || this.#runtimeSelectedToolNames !== undefined)
7276
+ ) {
7277
+ const writeNameIndex = validToolNames.indexOf("write");
7278
+ if (writeNameIndex >= 0 && this.#builtInToolNames.has("write")) validToolNames.splice(writeNameIndex, 1);
7279
+ const writeToolIndex = tools.findIndex(tool => tool.name === "write" && this.#builtInToolNames.has("write"));
7280
+ if (writeToolIndex >= 0) tools.splice(writeToolIndex, 1);
7281
+ }
7282
+
6775
7283
  const previousMounted = this.#mountedXdevToolNames;
7284
+ const previousMountedTools = [...previousMounted].flatMap(name => {
7285
+ const tool = this.#xdevRegistry?.get(name);
7286
+ return tool ? [tool] : [];
7287
+ });
7288
+ const previousActiveToolNames = this.getActiveToolNames();
6776
7289
  this.#mountedXdevToolNames = new Set(mountedTools.map(tool => tool.name));
6777
7290
  this.#xdevRegistry?.reconcile(mountedTools);
6778
- this.#notifyXdevMountDelta(previousMounted);
6779
7291
  this.#setActiveToolNames?.(validToolNames);
6780
- this.agent.setTools(tools);
6781
7292
 
6782
- // Rebuild base system prompt with new tool set, but only when the tool set
6783
- // actually changed. MCP servers can reconnect at arbitrary times and call
6784
- // `refreshMCPTools` -> `#applyActiveToolsByName` even though the resulting
6785
- // tool list is byte-identical. Skipping the rebuild keeps the system prompt
6786
- // stable, which is required for Anthropic prompt caching to keep hitting.
6787
- if (this.#rebuildSystemPrompt) {
6788
- const signature = this.#computeAppliedToolSignature(validToolNames, tools);
6789
- if (signature !== this.#lastAppliedToolSignature) {
6790
- if (this.#lastAppliedToolSignature !== undefined) {
6791
- this.#clearInheritedProviderPromptCacheKey();
7293
+ let rebuiltSystemPrompt: string[] | undefined;
7294
+ let rebuiltSignature: string | undefined;
7295
+ try {
7296
+ if (this.#rebuildSystemPrompt) {
7297
+ const signature = this.#computeAppliedToolSignature(validToolNames, tools);
7298
+ if (signature !== this.#lastAppliedToolSignature) {
7299
+ const built = await this.#rebuildSystemPrompt(validToolNames, this.#toolRegistry);
7300
+ rebuiltSystemPrompt = built.systemPrompt;
7301
+ rebuiltSignature = signature;
6792
7302
  }
6793
- const built = await this.#rebuildSystemPrompt(validToolNames, this.#toolRegistry);
6794
- this.#baseSystemPrompt = built.systemPrompt;
6795
- this.#baseSystemPromptBeforeMemoryPromotion = undefined;
6796
- this.agent.setSystemPrompt(this.#baseSystemPrompt);
6797
- this.#lastAppliedToolSignature = signature;
6798
- this.#promptModelKey = this.#currentPromptModelKey();
6799
7303
  }
7304
+ } catch (error) {
7305
+ this.#mountedXdevToolNames = previousMounted;
7306
+ this.#xdevRegistry?.reconcile(previousMountedTools);
7307
+ this.#setActiveToolNames?.(previousActiveToolNames);
7308
+ throw error;
7309
+ }
7310
+
7311
+ this.#notifyXdevMountDelta(previousMounted);
7312
+ this.agent.setTools(tools);
7313
+ if (rebuiltSystemPrompt && rebuiltSignature) {
7314
+ if (this.#lastAppliedToolSignature !== undefined) this.#clearInheritedProviderPromptCacheKey();
7315
+ this.#baseSystemPrompt = rebuiltSystemPrompt;
7316
+ this.#baseSystemPromptBeforeMemoryPromotion = undefined;
7317
+ this.agent.setSystemPrompt(this.#baseSystemPrompt);
7318
+ this.#lastAppliedToolSignature = rebuiltSignature;
7319
+ this.#promptModelKey = this.#currentPromptModelKey();
6800
7320
  }
6801
7321
  }
6802
7322
 
6803
7323
  /**
6804
- * Announce a mid-session `xd://` mount delta to the model as a steered
6805
- * system notice instead of rewriting the system prompt: the prompt (and
6806
- * its provider cache prefix) stays byte-stable across MCP connects and
6807
- * disconnects, and the model learns about new devices from the notice
6808
- * (docs + schema stay one `read xd://<tool>` away). The full docs join
6809
- * the system prompt opportunistically on the next unrelated rebuild.
7324
+ * Record a mid-session `xd://` mount delta for the model without rewriting
7325
+ * the system prompt: the prompt (and its provider cache prefix) stays
7326
+ * byte-stable across MCP connects and disconnects. The delta is NOT steered
7327
+ * immediately a steered notice landing at a run's stop boundary (or while
7328
+ * the session is idle) forces an unsolicited extra assistant turn it is
7329
+ * coalesced into {@link #pendingXdevMountDelta} and rides along with the
7330
+ * next prompt (docs + schema stay one `read xd://<tool>` away). The full
7331
+ * docs join the system prompt opportunistically on the next unrelated
7332
+ * rebuild.
6810
7333
  */
6811
7334
  #notifyXdevMountDelta(previousMounted: ReadonlySet<string>): void {
6812
7335
  const registry = this.#xdevRegistry;
6813
7336
  if (!registry) return;
6814
7337
  const current = this.#mountedXdevToolNames;
6815
7338
  const addedNames = [...current].filter(name => !previousMounted.has(name));
6816
- const removed = [...previousMounted].filter(name => !current.has(name)).map(name => ({ name }));
6817
- if (addedNames.length === 0 && removed.length === 0) return;
6818
- const summaries = new Map(registry.entries().map(entry => [entry.name, entry.summary]));
6819
- const added = addedNames.map(name => ({ name, summary: summaries.get(name) ?? "" }));
6820
- this.agent.steer({
7339
+ const removedNames = [...previousMounted].filter(name => !current.has(name));
7340
+ if (addedNames.length === 0 && removedNames.length === 0) return;
7341
+ // Coalesce against the unannounced delta: an unmount cancels a pending
7342
+ // mount the model never learned about, and a remount cancels a pending
7343
+ // unmount.
7344
+ const pending = this.#pendingXdevMountDelta ?? { added: new Set<string>(), removed: new Set<string>() };
7345
+ for (const name of addedNames) {
7346
+ if (!pending.removed.delete(name)) pending.added.add(name);
7347
+ }
7348
+ for (const name of removedNames) {
7349
+ if (!pending.added.delete(name)) pending.removed.add(name);
7350
+ }
7351
+ this.#pendingXdevMountDelta = pending.added.size > 0 || pending.removed.size > 0 ? pending : undefined;
7352
+ if (this.settings.get("startup.quiet")) return;
7353
+ const parts: string[] = [];
7354
+ if (addedNames.length > 0) parts.push(`mounted ${addedNames.join(", ")}`);
7355
+ if (removedNames.length > 0) parts.push(`unmounted ${removedNames.join(", ")}`);
7356
+ this.emitNotice("info", `xd://: ${parts.join("; ")}`, "xdev");
7357
+ }
7358
+
7359
+ /**
7360
+ * Render and consume the pending xd:// mount delta as a hidden notice, or
7361
+ * `undefined` when nothing unannounced is queued. Called from the prompt
7362
+ * paths so the notice rides along with user input instead of forcing its
7363
+ * own model turn.
7364
+ */
7365
+ #takePendingXdevMountNotice(): CustomMessage | undefined {
7366
+ const pending = this.#pendingXdevMountDelta;
7367
+ if (!pending) return undefined;
7368
+ this.#pendingXdevMountDelta = undefined;
7369
+ const summaries = new Map(this.#xdevRegistry?.entries().map(entry => [entry.name, entry.summary]) ?? []);
7370
+ const added = [...pending.added].map(name => ({ name, summary: summaries.get(name) ?? "" }));
7371
+ const removed = [...pending.removed].map(name => ({ name }));
7372
+ return {
6821
7373
  role: "custom",
6822
7374
  customType: XDEV_MOUNT_NOTICE_MESSAGE_TYPE,
6823
7375
  content: prompt.render(xdevMountNoticePrompt, { added, removed }),
6824
7376
  attribution: "agent",
6825
7377
  display: false,
6826
7378
  timestamp: Date.now(),
6827
- });
6828
- const parts: string[] = [];
6829
- if (added.length > 0) parts.push(`mounted ${added.map(entry => entry.name).join(", ")}`);
6830
- if (removed.length > 0) parts.push(`unmounted ${removed.map(entry => entry.name).join(", ")}`);
6831
- this.emitNotice("info", `xd://: ${parts.join("; ")}`, "xdev");
7379
+ };
6832
7380
  }
6833
7381
 
6834
7382
  /**
@@ -6866,7 +7414,24 @@ export class AgentSession {
6866
7414
  * Changes take effect before the next model call.
6867
7415
  */
6868
7416
  async setActiveToolsByName(toolNames: string[]): Promise<void> {
6869
- await this.#applyActiveToolsByName(toolNames);
7417
+ const mounted = this.#mountedXdevToolNames;
7418
+ const normalized = normalizeToolNames(toolNames);
7419
+ const transportWriteActive =
7420
+ this.#builtInToolNames.has("write") &&
7421
+ this.getActiveToolNames().includes("write") &&
7422
+ this.#presentationPinnedToolNames?.has("write") !== true &&
7423
+ this.#runtimeSelectedToolNames?.has("write") !== true &&
7424
+ (mounted.size > 0 || this.#planModeState?.enabled === true);
7425
+ const previousRuntimeSelectedToolNames = this.#runtimeSelectedToolNames;
7426
+ this.#runtimeSelectedToolNames = new Set(
7427
+ normalized.filter(name => !mounted.has(name) && !(name === "write" && transportWriteActive)),
7428
+ );
7429
+ try {
7430
+ await this.#applyActiveToolsByName(normalized);
7431
+ } catch (error) {
7432
+ this.#runtimeSelectedToolNames = previousRuntimeSelectedToolNames;
7433
+ throw error;
7434
+ }
6870
7435
  }
6871
7436
 
6872
7437
  /** Rebuild the base system prompt using the current active tool set. */
@@ -7009,6 +7574,12 @@ export class AgentSession {
7009
7574
  */
7010
7575
  async refreshMCPTools(mcpTools: CustomTool[]): Promise<void> {
7011
7576
  const existingNames = Array.from(this.#toolRegistry.keys());
7577
+ const previousMcpTools = new Map(
7578
+ existingNames.flatMap(name => {
7579
+ const tool = this.#toolRegistry.get(name);
7580
+ return isMCPToolName(name) && tool ? [[name, tool] as const] : [];
7581
+ }),
7582
+ );
7012
7583
  for (const name of existingNames) {
7013
7584
  if (isMCPToolName(name)) {
7014
7585
  this.#toolRegistry.delete(name);
@@ -7036,10 +7607,18 @@ export class AgentSession {
7036
7607
  this.#toolRegistry.set(finalTool.name, finalTool);
7037
7608
  }
7038
7609
 
7039
- // Every connected MCP tool is enabled; re-derive the active set from the
7040
- // current non-MCP tools plus all freshly registered MCP tools.
7610
+ // Every connected MCP tool is selected; centralized repartitioning owns
7611
+ // presentation pins and write-transport activation/removal.
7041
7612
  const nextActive = [...new Set([...this.#getActiveNonMCPToolNames(), ...mcpTools.map(tool => tool.name)])];
7042
- await this.#applyActiveToolsByName(nextActive);
7613
+ try {
7614
+ await this.#applyActiveToolsByName(nextActive);
7615
+ } catch (error) {
7616
+ for (const name of this.#toolRegistry.keys()) {
7617
+ if (isMCPToolName(name)) this.#toolRegistry.delete(name);
7618
+ }
7619
+ for (const [name, tool] of previousMcpTools) this.#toolRegistry.set(name, tool);
7620
+ throw error;
7621
+ }
7043
7622
  }
7044
7623
 
7045
7624
  /**
@@ -7060,6 +7639,12 @@ export class AgentSession {
7060
7639
 
7061
7640
  const previousRpcHostToolNames = new Set(this.#rpcHostToolNames);
7062
7641
  const previousActiveToolNames = this.getEnabledToolNames();
7642
+ const previousRpcHostTools = new Map(
7643
+ [...previousRpcHostToolNames].flatMap(name => {
7644
+ const tool = this.#toolRegistry.get(name);
7645
+ return tool ? [[name, tool] as const] : [];
7646
+ }),
7647
+ );
7063
7648
  for (const name of previousRpcHostToolNames) {
7064
7649
  this.#toolRegistry.delete(name);
7065
7650
  }
@@ -7081,9 +7666,16 @@ export class AgentSession {
7081
7666
  const autoActivatedRpcToolNames = rpcTools
7082
7667
  .filter(tool => !tool.hidden && !previousRpcHostToolNames.has(tool.name))
7083
7668
  .map(tool => tool.name);
7084
- await this.#applyActiveToolsByName(
7085
- Array.from(new Set([...activeNonRpcToolNames, ...preservedRpcToolNames, ...autoActivatedRpcToolNames])),
7086
- );
7669
+ try {
7670
+ await this.#applyActiveToolsByName(
7671
+ Array.from(new Set([...activeNonRpcToolNames, ...preservedRpcToolNames, ...autoActivatedRpcToolNames])),
7672
+ );
7673
+ } catch (error) {
7674
+ for (const name of this.#rpcHostToolNames) this.#toolRegistry.delete(name);
7675
+ this.#rpcHostToolNames = previousRpcHostToolNames;
7676
+ for (const [name, tool] of previousRpcHostTools) this.#toolRegistry.set(name, tool);
7677
+ throw error;
7678
+ }
7087
7679
  }
7088
7680
 
7089
7681
  /** Whether auto-compaction is currently running */
@@ -7411,6 +8003,11 @@ export class AgentSession {
7411
8003
  .filter((tool): tool is AgentTool => tool !== undefined)
7412
8004
  .map(tool => this.#wrapToolForAcpPermission(tool));
7413
8005
  this.agent.setTools(activeTools);
8006
+ const mountedTools = [...this.#mountedXdevToolNames]
8007
+ .map(name => this.#toolRegistry.get(name))
8008
+ .filter((tool): tool is AgentTool => tool !== undefined)
8009
+ .map(tool => this.#wrapToolForAcpPermission(tool));
8010
+ this.#xdevRegistry?.reconcile(mountedTools);
7414
8011
  }
7415
8012
 
7416
8013
  #clearCheckpointRuntimeState(): void {
@@ -8072,7 +8669,7 @@ export class AgentSession {
8072
8669
  const generation = this.#promptGeneration;
8073
8670
  try {
8074
8671
  // Flush any pending bash messages before the new prompt
8075
- this.#flushPendingBashMessages();
8672
+ await this.#flushPendingBashMessages();
8076
8673
  this.#flushPendingPythonMessages();
8077
8674
  this.#flushPendingIrcAsides();
8078
8675
 
@@ -8137,14 +8734,19 @@ export class AgentSession {
8137
8734
  messages.push(...options.prependMessages);
8138
8735
  }
8139
8736
 
8140
- messages.push(message);
8141
-
8142
8737
  // Early bail-out: if a newer abort/prompt cycle started during setup,
8143
8738
  // return before mutating shared state (nextTurn messages, system prompt).
8144
8739
  if (this.#promptGeneration !== generation) {
8145
8740
  return;
8146
8741
  }
8147
8742
 
8743
+ // A pending xd:// delta accompanies the next user-authored prompt,
8744
+ // never an agent-initiated continuation.
8745
+ const xdevMountNotice = isUserQueuedMessage(message) ? this.#takePendingXdevMountNotice() : undefined;
8746
+ if (xdevMountNotice) {
8747
+ messages.push(xdevMountNotice);
8748
+ }
8749
+ messages.push(message);
8148
8750
  // Inject any pending "nextTurn" messages as context alongside the user message
8149
8751
  for (const msg of this.#pendingNextTurnMessages) {
8150
8752
  messages.push(msg);
@@ -8346,9 +8948,20 @@ export class AgentSession {
8346
8948
  await this.reload();
8347
8949
  },
8348
8950
  getSystemPrompt: () => this.systemPrompt,
8951
+ setInterval: (callback, ms, ...args) => this.#fallbackTimers().setInterval(callback, ms, ...args),
8952
+ setTimeout: (callback, ms, ...args) => this.#fallbackTimers().setTimeout(callback, ms, ...args),
8953
+ clearTimer: timer => this.#fallbackTimers().clear(timer),
8349
8954
  };
8350
8955
  }
8351
8956
 
8957
+ /** Lazily create the runner-less command-context timer registry (#5664). */
8958
+ #fallbackTimers(): ManagedTimers {
8959
+ this.#fallbackExtensionTimers ??= new ManagedTimers((event, error) =>
8960
+ logger.warn("Extension timer callback threw", { event, error }),
8961
+ );
8962
+ return this.#fallbackExtensionTimers;
8963
+ }
8964
+
8352
8965
  /**
8353
8966
  * Try to execute a custom command. Returns the prompt string if found, null otherwise.
8354
8967
  * If the command returns void, returns empty string to indicate it was handled.
@@ -8982,16 +9595,26 @@ export class AgentSession {
8982
9595
  this.#replanTitleRefreshInFlight = refresh;
8983
9596
  }
8984
9597
 
8985
- async #refreshTitleAfterReplan(context: string, sessionId: string): Promise<void> {
8986
- const title = await generateSessionTitle(
8987
- context,
9598
+ /**
9599
+ * Generate an automatic session title tied to this session's lifecycle.
9600
+ * Input and replan callers share the signal so disposal cancels provider and
9601
+ * local-worker requests instead of leaving background inference alive.
9602
+ */
9603
+ generateTitle(firstMessage: string): Promise<string | null> {
9604
+ return generateSessionTitle(
9605
+ firstMessage,
8988
9606
  this.#modelRegistry,
8989
9607
  this.settings,
8990
- sessionId,
9608
+ this.sessionId,
8991
9609
  this.model,
8992
9610
  provider => this.agent.metadataForProvider(provider),
8993
9611
  this.#titleSystemPrompt,
9612
+ this.#titleGenerationAbortController.signal,
8994
9613
  );
9614
+ }
9615
+
9616
+ async #refreshTitleAfterReplan(context: string, sessionId: string): Promise<void> {
9617
+ const title = await this.generateTitle(context);
8995
9618
  if (!title) return;
8996
9619
  if (this.sessionManager.getSessionId() !== sessionId) return;
8997
9620
  if (!this.settings.get("title.refreshOnReplan")) return;
@@ -9059,6 +9682,7 @@ export class AgentSession {
9059
9682
  // auto-starting a fresh turn during cleanup.
9060
9683
  this.#abortInProgress = true;
9061
9684
  try {
9685
+ this.#abortAutolearnCapture();
9062
9686
  this.abortRetry();
9063
9687
  this.#promptGeneration++;
9064
9688
  this.#scheduledHiddenNextTurnGeneration = undefined;
@@ -9080,6 +9704,7 @@ export class AgentSession {
9080
9704
  this.agent.abort(options?.reason);
9081
9705
  await postPromptDrain;
9082
9706
  await this.agent.waitForIdle();
9707
+ await this.#drainAutolearnCapture();
9083
9708
  await this.#goalRuntime.onTaskAborted({ reason: options?.goalReason ?? "interrupted" });
9084
9709
  // Clear prompt-in-flight state: waitForIdle resolves when the agent loop's finally
9085
9710
  // block runs, but nested prompt setup/finalizers may still be unwinding. Without this,
@@ -9139,27 +9764,36 @@ export class AgentSession {
9139
9764
  await this.abort();
9140
9765
  this.#cancelOwnAsyncJobs();
9141
9766
  this.#closeAllProviderSessions("new session");
9142
- this.agent.reset();
9143
- if (options?.drop && previousSessionFile) {
9144
- // Detach the advisor recorder feed and drain its writer BEFORE deleting the
9145
- // old artifacts dir: `await this.abort()` only stops the primary, so a still-
9146
- // running advisor turn could otherwise finish, emit `message_end`, and recreate
9147
- // `<old>/__advisor.jsonl`. #resetAdvisorSessionState (after newSession) re-primes
9148
- // the advisor and re-attaches the feed at the new session's path.
9149
- for (const a of this.#advisors) {
9150
- a.agentUnsubscribe?.();
9151
- a.agentUnsubscribe = undefined;
9152
- await a.recorder.close();
9153
- }
9154
- try {
9155
- await this.sessionManager.dropSession(previousSessionFile);
9156
- } catch (err) {
9157
- logger.error("Failed to delete session during /drop", { err });
9767
+ await this.#flushPendingBashMessages();
9768
+ const bashTransition = this.#beginBashSessionTransition({ persistDetached: options?.drop !== true });
9769
+ let sessionTransitioned = false;
9770
+ try {
9771
+ this.agent.reset();
9772
+ if (options?.drop && previousSessionFile) {
9773
+ // Detach the advisor recorder feed and drain its writer BEFORE deleting the
9774
+ // old artifacts dir: `await this.abort()` only stops the primary, so a still-
9775
+ // running advisor turn could otherwise finish, emit `message_end`, and recreate
9776
+ // `<old>/__advisor.jsonl`. #resetAdvisorSessionState (after newSession) re-primes
9777
+ // the advisor and re-attaches the feed at the new session's path.
9778
+ for (const a of this.#advisors) {
9779
+ a.agentUnsubscribe?.();
9780
+ a.agentUnsubscribe = undefined;
9781
+ await a.recorder.close();
9782
+ }
9783
+ try {
9784
+ await this.sessionManager.dropSession(previousSessionFile);
9785
+ } catch (err) {
9786
+ logger.error("Failed to delete session during /drop", { err });
9787
+ }
9788
+ } else {
9789
+ await this.sessionManager.flush();
9158
9790
  }
9159
- } else {
9160
- await this.sessionManager.flush();
9791
+ await this.sessionManager.newSession(options);
9792
+ this.#markBashSessionTransition(bashTransition);
9793
+ sessionTransitioned = true;
9794
+ } finally {
9795
+ this.#finishBashSessionTransition(bashTransition, sessionTransitioned);
9161
9796
  }
9162
- await this.sessionManager.newSession(options);
9163
9797
 
9164
9798
  this.#clearCheckpointRuntimeState();
9165
9799
  this.setTodoPhases([]);
@@ -9225,14 +9859,25 @@ export class AgentSession {
9225
9859
  }
9226
9860
  }
9227
9861
 
9862
+ await this.#flushPendingBashMessages();
9228
9863
  // Flush current session to ensure all entries are written
9229
9864
  await this.sessionManager.flush();
9865
+ const bashTransition = this.#beginBashSessionTransition();
9230
9866
 
9231
9867
  // Fork the session (creates new session file with same entries)
9232
- const forkResult = await this.sessionManager.fork();
9868
+ let forkResult: { oldSessionFile: string; newSessionFile: string } | undefined;
9869
+ try {
9870
+ forkResult = await this.sessionManager.fork();
9871
+ } catch (error) {
9872
+ this.#finishBashSessionTransition(bashTransition, false);
9873
+ throw error;
9874
+ }
9233
9875
  if (!forkResult) {
9876
+ this.#finishBashSessionTransition(bashTransition, false);
9234
9877
  return false;
9235
9878
  }
9879
+ this.#markBashSessionTransition(bashTransition);
9880
+ this.#finishBashSessionTransition(bashTransition, true);
9236
9881
 
9237
9882
  // Copy artifacts directory if it exists
9238
9883
  const oldArtifactDir = forkResult.oldSessionFile.slice(0, -6);
@@ -10415,6 +11060,13 @@ export class AgentSession {
10415
11060
  this.#compactionAbortController = undefined;
10416
11061
  }
10417
11062
  this.#reconnectToAgent();
11063
+ // Compaction disconnected before `await abort()`, so abort's finally drain
11064
+ // (and any steer/follow-up that arrived mid-compaction — async IRC, an
11065
+ // `xd://` mount notice, an SDK/RPC steer) was suppressed while disconnected
11066
+ // (issue #5800). Unlike `/new`/switchSession, compaction preserves the agent
11067
+ // queues, so nothing else resumes them: re-drain now that the listener is back
11068
+ // and `isCompacting` is false, or the queued turn hangs until the next prompt.
11069
+ this.#drainStrandedQueuedMessages();
10418
11070
  }
10419
11071
  }
10420
11072
 
@@ -10608,9 +11260,20 @@ export class AgentSession {
10608
11260
  return undefined;
10609
11261
  }
10610
11262
  }
11263
+ await this.#flushPendingBashMessages();
10611
11264
  await this.sessionManager.flush();
11265
+ const bashTransition = this.#beginBashSessionTransition();
10612
11266
  this.#cancelOwnAsyncJobs();
10613
- await this.sessionManager.newSession(previousSessionFile ? { parentSession: previousSessionFile } : undefined);
11267
+ let sessionTransitioned = false;
11268
+ try {
11269
+ await this.sessionManager.newSession(
11270
+ previousSessionFile ? { parentSession: previousSessionFile } : undefined,
11271
+ );
11272
+ this.#markBashSessionTransition(bashTransition);
11273
+ sessionTransitioned = true;
11274
+ } finally {
11275
+ this.#finishBashSessionTransition(bashTransition, sessionTransitioned);
11276
+ }
10614
11277
 
10615
11278
  this.#clearCheckpointRuntimeState();
10616
11279
  // agent.reset() clears the core steering/follow-up queues. Preserve any queued
@@ -11070,6 +11733,7 @@ export class AgentSession {
11070
11733
  autoContinue,
11071
11734
  triggerContextTokens: postMaintenanceContextTokens,
11072
11735
  phase: "pre_turn",
11736
+ terminalTextAnswer: isTerminalTextAssistantAnswer(assistantMessage),
11073
11737
  });
11074
11738
  }
11075
11739
  logger.debug("Auto-compaction threshold satisfied but context promotion took over", {
@@ -11301,11 +11965,12 @@ export class AgentSession {
11301
11965
  #isEmptyAssistantStop(assistantMessage: AssistantMessage): boolean {
11302
11966
  switch (assistantMessage.stopReason) {
11303
11967
  case "stop":
11304
- // Reasoning/thinking-only turns are not actionable: they do not
11305
- // answer the user and do not give the agent loop a tool call to run.
11968
+ // Unsigned thinking alone is not actionable, but a signature is
11969
+ // provider-authenticated content and makes the stop terminal.
11306
11970
  for (const content of assistantMessage.content) {
11307
11971
  if (content.type === "toolCall") return false;
11308
11972
  if (content.type === "text" && hasNonWhitespace(content.text)) return false;
11973
+ if (content.type === "thinking" && hasNonWhitespace(content.thinkingSignature ?? "")) return false;
11309
11974
  }
11310
11975
  return true;
11311
11976
  case "toolUse":
@@ -11503,11 +12168,13 @@ export class AgentSession {
11503
12168
 
11504
12169
  if (!branchEntry) return;
11505
12170
  const targetParentId = prunePrompt ? parentEntry.parentId : branchEntry.parentId;
11506
- if (targetParentId === null) {
11507
- this.sessionManager.resetLeaf();
11508
- } else {
11509
- this.sessionManager.branch(targetParentId);
11510
- }
12171
+ this.#withBashBranchTransition(() => {
12172
+ if (targetParentId === null) {
12173
+ this.sessionManager.resetLeaf();
12174
+ } else {
12175
+ this.sessionManager.branch(targetParentId);
12176
+ }
12177
+ });
11511
12178
  this.sessionManager.appendCustomEntry("accepted-terminal-empty-stop");
11512
12179
  }
11513
12180
 
@@ -11534,11 +12201,13 @@ export class AgentSession {
11534
12201
  if (!branchEntry) {
11535
12202
  return;
11536
12203
  }
11537
- if (branchEntry.parentId === null) {
11538
- this.sessionManager.resetLeaf();
11539
- } else {
11540
- this.sessionManager.branch(branchEntry.parentId);
11541
- }
12204
+ this.#withBashBranchTransition(() => {
12205
+ if (branchEntry.parentId === null) {
12206
+ this.sessionManager.resetLeaf();
12207
+ } else {
12208
+ this.sessionManager.branch(branchEntry.parentId);
12209
+ }
12210
+ });
11542
12211
  }
11543
12212
 
11544
12213
  #isSameAssistantMessage(left: AssistantMessage, right: AssistantMessage): boolean {
@@ -11575,8 +12244,10 @@ export class AgentSession {
11575
12244
  if (this.#pendingRewindReport) return this.#pendingRewindReport;
11576
12245
  for (let i = messages.length - 1; i >= 0; i--) {
11577
12246
  const message = messages[i];
11578
- if (message?.role !== "toolResult" || message.toolName !== "rewind" || message.isError) continue;
11579
- const details = message.details;
12247
+ if (message?.role !== "toolResult" || message.isError) continue;
12248
+ const semanticResult = semanticToolResult(message.toolName, message);
12249
+ if (semanticResult?.toolName !== "rewind") continue;
12250
+ const details = semanticResult.details;
11580
12251
  const detailReport =
11581
12252
  details && typeof details === "object" && "report" in details && typeof details.report === "string"
11582
12253
  ? details.report.trim()
@@ -11593,16 +12264,18 @@ export class AgentSession {
11593
12264
  if (!checkpointState) {
11594
12265
  return;
11595
12266
  }
11596
- try {
11597
- this.sessionManager.branchWithSummary(checkpointState.checkpointEntryId, report, {
11598
- startedAt: checkpointState.startedAt,
11599
- });
11600
- } catch (error) {
11601
- logger.warn("Rewind branch checkpoint missing, falling back to root", {
11602
- error: error instanceof Error ? error.message : String(error),
11603
- });
11604
- this.sessionManager.branchWithSummary(null, report, { startedAt: checkpointState.startedAt });
11605
- }
12267
+ this.#withBashBranchTransition(() => {
12268
+ try {
12269
+ this.sessionManager.branchWithSummary(checkpointState.checkpointEntryId, report, {
12270
+ startedAt: checkpointState.startedAt,
12271
+ });
12272
+ } catch (error) {
12273
+ logger.warn("Rewind branch checkpoint missing, falling back to root", {
12274
+ error: error instanceof Error ? error.message : String(error),
12275
+ });
12276
+ this.sessionManager.branchWithSummary(null, report, { startedAt: checkpointState.startedAt });
12277
+ }
12278
+ });
11606
12279
 
11607
12280
  const rewoundAt = new Date().toISOString();
11608
12281
  const details = { report, startedAt: checkpointState.startedAt, rewoundAt };
@@ -11617,7 +12290,7 @@ export class AgentSession {
11617
12290
 
11618
12291
  if (activeMessages) {
11619
12292
  for (const message of activeMessages) {
11620
- if (message.role === "toolResult" && message.toolName === "rewind") {
12293
+ if (message.role === "toolResult" && semanticToolResult(message.toolName, message)?.toolName === "rewind") {
11621
12294
  this.#rewoundToolResultIds.add(message.toolCallId);
11622
12295
  }
11623
12296
  }
@@ -12916,12 +13589,15 @@ export class AgentSession {
12916
13589
  suppressContinuation?: boolean;
12917
13590
  suppressHandoff?: boolean;
12918
13591
  phase?: CodexCompactionContext["phase"];
13592
+ terminalTextAnswer?: boolean;
12919
13593
  } = {},
12920
13594
  ): Promise<CompactionCheckResult> {
12921
13595
  const compactionSettings = this.settings.getGroup("compaction");
12922
13596
  if (compactionSettings.strategy === "off") return COMPACTION_CHECK_NONE;
12923
13597
  if (reason !== "idle" && !compactionSettings.enabled) return COMPACTION_CHECK_NONE;
12924
13598
  const generation = this.#promptGeneration;
13599
+ const terminalTextAnswer =
13600
+ options.terminalTextAnswer ?? isTerminalTextAssistantAnswer(this.#findLastAssistantMessage());
12925
13601
  const suppressContinuation = options.suppressContinuation === true;
12926
13602
  const shouldAutoContinue =
12927
13603
  !suppressContinuation && options.autoContinue !== false && compactionSettings.autoContinue !== false;
@@ -12936,6 +13612,7 @@ export class AgentSession {
12936
13612
  willRetry,
12937
13613
  generation,
12938
13614
  shouldAutoContinue,
13615
+ terminalTextAnswer,
12939
13616
  options.triggerContextTokens,
12940
13617
  suppressContinuation,
12941
13618
  );
@@ -12958,11 +13635,17 @@ export class AgentSession {
12958
13635
  async signal => {
12959
13636
  await Promise.resolve();
12960
13637
  if (signal.aborted) return;
12961
- await this.#runAutoCompaction(reason, willRetry, true, true, { phase: options.phase });
13638
+ await this.#runAutoCompaction(reason, willRetry, true, true, {
13639
+ ...options,
13640
+ terminalTextAnswer,
13641
+ });
12962
13642
  },
12963
13643
  { generation },
12964
13644
  );
12965
- return COMPACTION_CHECK_DEFERRED_HANDOFF;
13645
+ return {
13646
+ ...COMPACTION_CHECK_DEFERRED_HANDOFF,
13647
+ continuationScheduled: shouldAutoContinue,
13648
+ };
12966
13649
  }
12967
13650
 
12968
13651
  // "overflow" forces context-full because the input itself is broken — a handoff
@@ -13029,10 +13712,14 @@ export class AgentSession {
13029
13712
  aborted: false,
13030
13713
  willRetry: false,
13031
13714
  });
13032
- const continuationScheduled = !autoCompactionSignal.aborted && reason !== "idle" && shouldAutoContinue;
13033
- if (continuationScheduled) {
13034
- this.#scheduleAutoContinuePrompt(generation);
13035
- }
13715
+ const continuationScheduled =
13716
+ !autoCompactionSignal.aborted &&
13717
+ this.#scheduleCompactionContinuation({
13718
+ generation,
13719
+ autoContinue: reason !== "idle" && shouldAutoContinue,
13720
+ terminalTextAnswer,
13721
+ suppressContinuation,
13722
+ });
13036
13723
  return {
13037
13724
  ...(continuationScheduled ? COMPACTION_CHECK_CONTINUATION : COMPACTION_CHECK_NONE),
13038
13725
  historyRewritten: true,
@@ -13521,20 +14208,13 @@ export class AgentSession {
13521
14208
  if (retryFits) {
13522
14209
  this.#scheduleAgentContinue({ delayMs: 100, generation });
13523
14210
  continuationScheduled = true;
13524
- } else if (hasHeadroom && shouldAutoContinue) {
13525
- this.#scheduleAutoContinuePrompt(generation);
13526
- continuationScheduled = true;
13527
- }
13528
- if (!continuationScheduled && !suppressContinuation && this.agent.hasQueuedMessages()) {
13529
- // Auto-compaction can complete while follow-up/steering/custom messages are waiting.
13530
- // Kick the loop so queued messages are actually delivered. This remains separate
13531
- // from the no-progress warning: pausing maintenance must not strand user input.
13532
- this.#scheduleAgentContinue({
13533
- delayMs: 100,
14211
+ } else {
14212
+ continuationScheduled = this.#scheduleCompactionContinuation({
13534
14213
  generation,
13535
- shouldContinue: () => this.agent.hasQueuedMessages(),
14214
+ autoContinue: hasHeadroom && shouldAutoContinue,
14215
+ terminalTextAnswer,
14216
+ suppressContinuation,
13536
14217
  });
13537
- continuationScheduled = true;
13538
14218
  }
13539
14219
 
13540
14220
  if (deadEndWarning) {
@@ -13590,6 +14270,7 @@ export class AgentSession {
13590
14270
  willRetry: boolean,
13591
14271
  generation: number,
13592
14272
  autoContinue: boolean,
14273
+ terminalTextAnswer: boolean,
13593
14274
  triggerContextTokens?: number,
13594
14275
  suppressContinuation = false,
13595
14276
  ): Promise<CompactionCheckResult | "fallback"> {
@@ -13672,10 +14353,6 @@ export class AgentSession {
13672
14353
  });
13673
14354
 
13674
14355
  let continuationScheduled = false;
13675
- if (!willRetry && reason !== "idle" && autoContinue) {
13676
- this.#scheduleAutoContinuePrompt(generation);
13677
- continuationScheduled = true;
13678
- }
13679
14356
  if (willRetry) {
13680
14357
  // The shake rebuild replays every entry, so a trailing error/length
13681
14358
  // assistant from the failed turn re-enters agent state — drop it before
@@ -13691,13 +14368,13 @@ export class AgentSession {
13691
14368
  }
13692
14369
  this.#scheduleAgentContinue({ delayMs: 100, generation });
13693
14370
  continuationScheduled = true;
13694
- } else if (!suppressContinuation && this.agent.hasQueuedMessages()) {
13695
- this.#scheduleAgentContinue({
13696
- delayMs: 100,
14371
+ } else {
14372
+ continuationScheduled = this.#scheduleCompactionContinuation({
13697
14373
  generation,
13698
- shouldContinue: () => this.agent.hasQueuedMessages(),
14374
+ autoContinue: reason !== "idle" && autoContinue,
14375
+ terminalTextAnswer,
14376
+ suppressContinuation,
13699
14377
  });
13700
- continuationScheduled = true;
13701
14378
  }
13702
14379
  if (!reclaimed) {
13703
14380
  return willRetry && continuationScheduled
@@ -13851,6 +14528,11 @@ export class AgentSession {
13851
14528
  return stopType === "refusal" || stopType === "sensitive";
13852
14529
  }
13853
14530
 
14531
+ /** True when any registered model belongs to `provider`. */
14532
+ #hasProviderModels(provider: string): boolean {
14533
+ return this.#modelRegistry.getAll().some(model => model.provider === provider);
14534
+ }
14535
+
13854
14536
  #getRetryFallbackChains(): RetryFallbackChains {
13855
14537
  const configuredChains = this.settings.get("retry.fallbackChains");
13856
14538
  if (!configuredChains || typeof configuredChains !== "object") return {};
@@ -13881,8 +14563,8 @@ export class AgentSession {
13881
14563
  const keyKind = isRetryFallbackModelKey(key) ? "model" : "role";
13882
14564
  if (keyKind === "model") {
13883
14565
  if (isRetryFallbackWildcardKey(key)) {
13884
- const provider = key.slice(0, -2);
13885
- if (!this.#modelRegistry.getAll().some(model => model.provider === provider)) {
14566
+ const { provider } = parseRetryFallbackWildcard(key, p => this.#hasProviderModels(p));
14567
+ if (!this.#hasProviderModels(provider)) {
13886
14568
  const msg = `retry.fallbackChains wildcard key references unknown provider: ${key}`;
13887
14569
  logger.warn(msg);
13888
14570
  this.configWarnings.push(msg);
@@ -13914,8 +14596,8 @@ export class AgentSession {
13914
14596
  continue;
13915
14597
  }
13916
14598
  if (isRetryFallbackWildcardKey(selectorStr)) {
13917
- const provider = selectorStr.slice(0, -2);
13918
- if (!this.#modelRegistry.getAll().some(model => model.provider === provider)) {
14599
+ const { provider } = parseRetryFallbackWildcard(selectorStr, p => this.#hasProviderModels(p));
14600
+ if (!this.#hasProviderModels(provider)) {
13919
14601
  const msg = `Fallback chain for ${keyKind} '${key}' references unknown provider: ${selectorStr}`;
13920
14602
  logger.warn(msg);
13921
14603
  this.configWarnings.push(msg);
@@ -13974,13 +14656,16 @@ export class AgentSession {
13974
14656
  * Model-oriented keys win over roles so a chain follows the model across
13975
14657
  * role reassignments.
13976
14658
  */
13977
- #resolveRetryFallbackRole(currentSelector: string): string | undefined {
14659
+ #resolveRetryFallbackRole(
14660
+ currentSelector: string,
14661
+ currentModel: Model | null | undefined = this.model,
14662
+ ): string | undefined {
13978
14663
  const parsedCurrent = parseRetryFallbackSelector(currentSelector, this.#modelRegistry);
13979
14664
  if (!parsedCurrent) return undefined;
13980
14665
  const chains = this.#getRetryFallbackChains();
13981
14666
  const currentBaseSelector = formatRetryFallbackBaseSelector(parsedCurrent);
13982
- const currentPlainSelector = this.model
13983
- ? formatModelSelectorValue(formatModelString(this.model), parsedCurrent.thinkingLevel)
14667
+ const currentPlainSelector = currentModel
14668
+ ? formatModelSelectorValue(formatModelString(currentModel), parsedCurrent.thinkingLevel)
13984
14669
  : undefined;
13985
14670
  const currentPlainBaseSelector =
13986
14671
  currentPlainSelector && currentPlainSelector !== currentSelector
@@ -14006,9 +14691,22 @@ export class AgentSession {
14006
14691
  for (const key of exactModelKeys) {
14007
14692
  if (matchesCurrent(this.#getRetryFallbackPrimarySelector(key))) return key;
14008
14693
  }
14009
- // 2. Provider wildcard (`provider/*`) any active model of this provider.
14010
- const wildcardKey = `${parsedCurrent.provider}/*`;
14011
- if (Array.isArray(chains[wildcardKey])) return wildcardKey;
14694
+ // 2. Provider wildcardsan id-prefixed key (`openrouter/google/*`)
14695
+ // beats the plain `provider/*` key for ids under its prefix.
14696
+ let wildcardMatch: string | undefined;
14697
+ let wildcardPrefixLength = -1;
14698
+ for (const key in chains) {
14699
+ if (!isRetryFallbackWildcardKey(key) || !Array.isArray(chains[key])) continue;
14700
+ const { provider, idPrefix } = parseRetryFallbackWildcard(key, p => this.#hasProviderModels(p));
14701
+ if (provider !== parsedCurrent.provider) continue;
14702
+ if (idPrefix !== undefined && !parsedCurrent.id.startsWith(`${idPrefix}/`)) continue;
14703
+ const prefixLength = idPrefix === undefined ? 0 : idPrefix.length;
14704
+ if (prefixLength > wildcardPrefixLength) {
14705
+ wildcardMatch = key;
14706
+ wildcardPrefixLength = prefixLength;
14707
+ }
14708
+ }
14709
+ if (wildcardMatch) return wildcardMatch;
14012
14710
  // 3. Role keys — matched by the role's currently-assigned model.
14013
14711
  for (const key of roleKeys) {
14014
14712
  if (matchesCurrent(this.#getRetryFallbackPrimarySelector(key))) return key;
@@ -14027,9 +14725,11 @@ export class AgentSession {
14027
14725
 
14028
14726
  /**
14029
14727
  * Parse one configured chain entry. A `provider/*` entry keeps the failing
14030
- * model's id and swaps the provider (google-antigravity/x → google/x);
14031
- * ids the target provider lacks are skipped by the candidate loop's
14032
- * registry lookup.
14728
+ * model's id and swaps the provider (google-antigravity/x → google/x); an
14729
+ * id-prefixed `provider/prefix/*` entry re-prefixes the failing model's
14730
+ * bare id instead (openrouter/google/* : google-antigravity/x →
14731
+ * openrouter/google/x). Ids the target provider lacks are skipped by the
14732
+ * candidate loop's registry lookup.
14033
14733
  */
14034
14734
  #parseRetryFallbackChainEntry(
14035
14735
  entry: string,
@@ -14037,8 +14737,23 @@ export class AgentSession {
14037
14737
  ): RetryFallbackSelector | undefined {
14038
14738
  if (isRetryFallbackWildcardKey(entry)) {
14039
14739
  if (!current) return undefined;
14040
- const provider = entry.slice(0, -2);
14041
- return { raw: `${provider}/${current.id}`, provider, id: current.id, thinkingLevel: undefined };
14740
+ const { provider, idPrefix } = parseRetryFallbackWildcard(entry, p => this.#hasProviderModels(p));
14741
+ const bareId = current.id.slice(current.id.lastIndexOf("/") + 1);
14742
+ let id: string;
14743
+ if (idPrefix !== undefined) {
14744
+ id = `${idPrefix}/${bareId}`;
14745
+ } else if (
14746
+ bareId !== current.id &&
14747
+ !this.#modelRegistry.find(provider, current.id) &&
14748
+ this.#modelRegistry.find(provider, bareId)
14749
+ ) {
14750
+ // Aggregator → direct: the failing id carries a vendor prefix the
14751
+ // target provider does not use (openrouter/google/x → google-vertex/x).
14752
+ id = bareId;
14753
+ } else {
14754
+ id = current.id;
14755
+ }
14756
+ return { raw: `${provider}/${id}`, provider, id, thinkingLevel: undefined };
14042
14757
  }
14043
14758
  return parseRetryFallbackSelector(entry, this.#modelRegistry);
14044
14759
  }
@@ -14071,7 +14786,11 @@ export class AgentSession {
14071
14786
  return chain;
14072
14787
  }
14073
14788
 
14074
- #findRetryFallbackCandidates(role: string, currentSelector: string): RetryFallbackSelector[] {
14789
+ #findRetryFallbackCandidates(
14790
+ role: string,
14791
+ currentSelector: string,
14792
+ currentModel: Model | null | undefined = this.model,
14793
+ ): RetryFallbackSelector[] {
14075
14794
  let chain = this.#getRetryFallbackEffectiveChain(role, currentSelector);
14076
14795
  const parsedCurrent = parseRetryFallbackSelector(currentSelector, this.#modelRegistry);
14077
14796
  if (chain.length === 0 && role === "default" && parsedCurrent) {
@@ -14095,8 +14814,8 @@ export class AgentSession {
14095
14814
  if (chain.length <= 1) return [];
14096
14815
  const currentBaseSelector = parsedCurrent ? formatRetryFallbackBaseSelector(parsedCurrent) : undefined;
14097
14816
  const currentPlainSelector =
14098
- this.model && parsedCurrent
14099
- ? formatModelSelectorValue(formatModelString(this.model), parsedCurrent.thinkingLevel)
14817
+ currentModel && parsedCurrent
14818
+ ? formatModelSelectorValue(formatModelString(currentModel), parsedCurrent.thinkingLevel)
14100
14819
  : undefined;
14101
14820
  const currentPlainBaseSelector =
14102
14821
  parsedCurrent && currentPlainSelector && currentPlainSelector !== currentSelector
@@ -14706,17 +15425,187 @@ export class AgentSession {
14706
15425
  // Bash Execution
14707
15426
  // =========================================================================
14708
15427
 
14709
- async #saveBashOriginalArtifact(originalText: string): Promise<string | undefined> {
15428
+ async #saveBashOriginalArtifact(target: BashSessionTarget, originalText: string): Promise<string | undefined> {
14710
15429
  try {
14711
- return await this.sessionManager.saveArtifact(originalText, "bash-original");
15430
+ const destination = target.destination ?? (await target.pending);
15431
+ return await destination?.manager.saveArtifact(originalText, "bash-original");
14712
15432
  } catch {
14713
15433
  return undefined;
14714
15434
  }
14715
15435
  }
14716
15436
 
15437
+ #createBashMessage(
15438
+ command: string,
15439
+ result: BashResult,
15440
+ options?: { excludeFromContext?: boolean },
15441
+ ): BashExecutionMessage {
15442
+ const meta = outputMeta().truncationFromSummary(result, { direction: "tail" }).get();
15443
+ return {
15444
+ role: "bashExecution",
15445
+ command,
15446
+ output: result.output,
15447
+ exitCode: result.exitCode,
15448
+ cancelled: result.cancelled,
15449
+ truncated: result.truncated,
15450
+ meta,
15451
+ timestamp: Date.now(),
15452
+ excludeFromContext: options?.excludeFromContext,
15453
+ };
15454
+ }
15455
+
15456
+ #captureBashSessionTarget(): BashSessionTarget {
15457
+ this.#bashSessionTarget.refs++;
15458
+ return this.#bashSessionTarget;
15459
+ }
15460
+
15461
+ async #releaseBashSessionTarget(target: BashSessionTarget): Promise<void> {
15462
+ if (target.refs <= 0) throw new Error("Bash session target released more than once");
15463
+ target.refs--;
15464
+ if (target.refs === 0 && target.destination?.kind === "detached") {
15465
+ await target.destination.manager.close();
15466
+ }
15467
+ }
15468
+
15469
+ #appendBashMessage(destination: BashAppendDestination, message: BashExecutionMessage): void {
15470
+ switch (destination.kind) {
15471
+ case "current":
15472
+ this.agent.appendMessage(message);
15473
+ destination.manager.appendMessage(message);
15474
+ break;
15475
+ case "detached":
15476
+ destination.manager.appendMessage(message);
15477
+ break;
15478
+ case "branch":
15479
+ destination.parentId = destination.manager.appendMessageToBranch(message, destination.parentId);
15480
+ break;
15481
+ }
15482
+ }
15483
+
15484
+ async #appendOwnedBashMessage(target: BashSessionTarget, message: BashExecutionMessage): Promise<void> {
15485
+ try {
15486
+ const destination = target.destination ?? (await target.pending);
15487
+ if (!destination) throw new Error("Bash session target has no append destination");
15488
+ this.#appendBashMessage(destination, message);
15489
+ } finally {
15490
+ await this.#releaseBashSessionTarget(target);
15491
+ }
15492
+ }
15493
+
15494
+ async #recordBashResultForTarget(
15495
+ target: BashSessionTarget,
15496
+ command: string,
15497
+ result: BashResult,
15498
+ options?: { excludeFromContext?: boolean },
15499
+ ): Promise<void> {
15500
+ const message = this.#createBashMessage(command, result, options);
15501
+ if (this.isStreaming && target === this.#bashSessionTarget) {
15502
+ this.#pendingBashMessages.push({ target, message });
15503
+ return;
15504
+ }
15505
+ await this.#appendOwnedBashMessage(target, message);
15506
+ }
15507
+
15508
+ /** Run a leaf rewrite while retaining any in-flight bash on its originating branch. */
15509
+ #withBashBranchTransition<T>(mutate: () => T): T {
15510
+ const bashTransition = this.#beginBashSessionTransition();
15511
+ let branchTransitioned = false;
15512
+ try {
15513
+ const result = mutate();
15514
+ this.#markBashSessionTransition(bashTransition);
15515
+ branchTransitioned = true;
15516
+ return result;
15517
+ } finally {
15518
+ this.#finishBashSessionTransition(bashTransition, branchTransitioned);
15519
+ }
15520
+ }
15521
+
14717
15522
  /**
14718
- * Execute a bash command.
14719
- * Adds result to agent context and session.
15523
+ * Snapshot the session/branch that owns any in-flight bash before a transition.
15524
+ * When an owner is still active, its target is detached to a clone so a failed
15525
+ * or intentionally dropped transition never redirects the late result.
15526
+ */
15527
+ #beginBashSessionTransition(options?: { persistDetached?: boolean }): BashSessionTransition {
15528
+ const oldTarget = this.#bashSessionTarget;
15529
+ let detachedManager: SessionManager | undefined;
15530
+ let resolveOld: ((destination: BashAppendDestination) => void) | undefined;
15531
+ if (oldTarget.refs > 0) {
15532
+ detachedManager = this.sessionManager.cloneCurrentSession({ persist: options?.persistDetached });
15533
+ const pendingOld = Promise.withResolvers<BashAppendDestination>();
15534
+ oldTarget.destination = undefined;
15535
+ oldTarget.pending = pendingOld.promise;
15536
+ resolveOld = pendingOld.resolve;
15537
+ }
15538
+
15539
+ const pendingNew = Promise.withResolvers<BashAppendDestination>();
15540
+ return {
15541
+ oldTarget,
15542
+ newTarget: {
15543
+ sessionId: this.sessionManager.getSessionId(),
15544
+ refs: 0,
15545
+ pending: pendingNew.promise,
15546
+ },
15547
+ oldSessionId: this.sessionManager.getSessionId(),
15548
+ oldSessionFile: this.sessionManager.getSessionFile(),
15549
+ oldLeafId: this.sessionManager.getLeafId(),
15550
+ detachedManager,
15551
+ resolveOld,
15552
+ resolveNew: pendingNew.resolve,
15553
+ };
15554
+ }
15555
+
15556
+ /** Adopt the transition's new target as the live bash owner. */
15557
+ #markBashSessionTransition(transition: BashSessionTransition): void {
15558
+ transition.newTarget.sessionId = this.sessionManager.getSessionId();
15559
+ this.#bashSessionTarget = transition.newTarget;
15560
+ }
15561
+
15562
+ /**
15563
+ * Resolve the pending append destinations opened by {@link #beginBashSessionTransition}.
15564
+ * On success the old owner keeps its original session/branch (same file → current or
15565
+ * branch destination; different file → detached clone); on failure both fall back to
15566
+ * the still-current manager and the clone is discarded.
15567
+ */
15568
+ #finishBashSessionTransition(transition: BashSessionTransition, success: boolean): void {
15569
+ const currentDestination: BashAppendDestination = { kind: "current", manager: this.sessionManager };
15570
+ let oldDestination: BashAppendDestination = currentDestination;
15571
+ if (success && transition.resolveOld) {
15572
+ const currentFile = this.sessionManager.getSessionFile();
15573
+ const sameFile =
15574
+ transition.oldSessionFile === currentFile ||
15575
+ (transition.oldSessionFile !== undefined &&
15576
+ currentFile !== undefined &&
15577
+ path.resolve(transition.oldSessionFile) === path.resolve(currentFile));
15578
+ const sameSession = transition.oldSessionId === this.sessionManager.getSessionId() && sameFile;
15579
+ if (sameSession) {
15580
+ oldDestination =
15581
+ transition.oldLeafId === this.sessionManager.getLeafId()
15582
+ ? currentDestination
15583
+ : { kind: "branch", manager: this.sessionManager, parentId: transition.oldLeafId };
15584
+ } else if (transition.detachedManager) {
15585
+ oldDestination = { kind: "detached", manager: transition.detachedManager };
15586
+ }
15587
+ }
15588
+
15589
+ if (transition.resolveOld) {
15590
+ transition.oldTarget.pending = undefined;
15591
+ transition.oldTarget.destination = oldDestination;
15592
+ transition.resolveOld(oldDestination);
15593
+ }
15594
+
15595
+ transition.newTarget.pending = undefined;
15596
+ transition.newTarget.destination = currentDestination;
15597
+ if (!success) transition.newTarget.sessionId = this.sessionManager.getSessionId();
15598
+ transition.resolveNew(currentDestination);
15599
+
15600
+ if (transition.detachedManager && (oldDestination.kind !== "detached" || transition.oldTarget.refs === 0)) {
15601
+ void transition.detachedManager.close().catch(error => {
15602
+ logger.warn("Failed to close detached bash session writer", { error: String(error) });
15603
+ });
15604
+ }
15605
+ }
15606
+
15607
+ /**
15608
+ * Execute a bash command and retain the session/branch that owned its start.
14720
15609
  * @param command The bash command to execute
14721
15610
  * @param onChunk Optional streaming callback for output
14722
15611
  * @param options.excludeFromContext If true, command output won't be sent to LLM (!! prefix)
@@ -14727,72 +15616,72 @@ export class AgentSession {
14727
15616
  onChunk?: (chunk: string) => void,
14728
15617
  options?: { excludeFromContext?: boolean; useUserShell?: boolean },
14729
15618
  ): Promise<BashResult> {
15619
+ const target = this.#captureBashSessionTarget();
15620
+ let targetTransferred = false;
14730
15621
  const excludeFromContext = options?.excludeFromContext === true;
14731
15622
  const cwd = this.sessionManager.getCwd();
14732
15623
 
14733
- if (this.#extensionRunner?.hasHandlers("user_bash")) {
14734
- const hookResult = await this.#extensionRunner.emitUserBash({
14735
- type: "user_bash",
14736
- command,
14737
- excludeFromContext,
14738
- cwd,
14739
- });
14740
- if (hookResult?.result) {
14741
- this.recordBashResult(command, hookResult.result, options);
14742
- return hookResult.result;
15624
+ try {
15625
+ if (this.#extensionRunner?.hasHandlers("user_bash")) {
15626
+ const hookResult = await this.#extensionRunner.emitUserBash({
15627
+ type: "user_bash",
15628
+ command,
15629
+ excludeFromContext,
15630
+ cwd,
15631
+ });
15632
+ if (hookResult?.result) {
15633
+ targetTransferred = true;
15634
+ await this.#recordBashResultForTarget(target, command, hookResult.result, options);
15635
+ return hookResult.result;
15636
+ }
14743
15637
  }
14744
- }
14745
15638
 
14746
- const abortController = new AbortController();
14747
- this.#bashAbortControllers.add(abortController);
14748
-
14749
- try {
14750
- const result = await executeBashCommand(command, {
14751
- onChunk,
14752
- signal: abortController.signal,
14753
- sessionKey: this.sessionId,
14754
- cwd,
14755
- timeout: clampTimeout("bash") * 1000,
14756
- onMinimizedSave: originalText => this.#saveBashOriginalArtifact(originalText),
14757
- useUserShell: options?.useUserShell,
14758
- });
15639
+ const abortController = new AbortController();
15640
+ this.#bashAbortControllers.add(abortController);
15641
+ let result: BashResult;
15642
+ try {
15643
+ result = await executeBashCommand(command, {
15644
+ onChunk,
15645
+ signal: abortController.signal,
15646
+ sessionKey: target.sessionId,
15647
+ cwd,
15648
+ timeout: clampTimeout("bash") * 1000,
15649
+ onMinimizedSave: originalText => this.#saveBashOriginalArtifact(target, originalText),
15650
+ useUserShell: options?.useUserShell,
15651
+ });
15652
+ } finally {
15653
+ this.#bashAbortControllers.delete(abortController);
15654
+ }
14759
15655
 
14760
- this.recordBashResult(command, result, options);
15656
+ targetTransferred = true;
15657
+ await this.#recordBashResultForTarget(target, command, result, options);
14761
15658
  return result;
14762
15659
  } finally {
14763
- this.#bashAbortControllers.delete(abortController);
15660
+ if (!targetTransferred) await this.#releaseBashSessionTarget(target);
14764
15661
  }
14765
15662
  }
14766
15663
 
14767
- /**
14768
- * Record a bash execution result in session history.
14769
- * Used by executeBash and by extensions that handle bash execution themselves.
14770
- */
15664
+ /** Record a bash result supplied outside executeBash in the current ownership scope. */
14771
15665
  recordBashResult(command: string, result: BashResult, options?: { excludeFromContext?: boolean }): void {
14772
- const meta = outputMeta().truncationFromSummary(result, { direction: "tail" }).get();
14773
- const bashMessage: BashExecutionMessage = {
14774
- role: "bashExecution",
14775
- command,
14776
- output: result.output,
14777
- exitCode: result.exitCode,
14778
- cancelled: result.cancelled,
14779
- truncated: result.truncated,
14780
- meta,
14781
- timestamp: Date.now(),
14782
- excludeFromContext: options?.excludeFromContext,
14783
- };
14784
-
14785
- // If agent is streaming, defer adding to avoid breaking tool_use/tool_result ordering
14786
- if (this.isStreaming) {
14787
- // Queue for later - will be flushed on agent_end
14788
- this.#pendingBashMessages.push(bashMessage);
14789
- } else {
14790
- // Add to agent state immediately
14791
- this.agent.appendMessage(bashMessage);
15666
+ const target = this.#captureBashSessionTarget();
15667
+ const message = this.#createBashMessage(command, result, options);
15668
+ if (this.isStreaming && target === this.#bashSessionTarget) {
15669
+ this.#pendingBashMessages.push({ target, message });
15670
+ return;
15671
+ }
14792
15672
 
14793
- // Save to session
14794
- this.sessionManager.appendMessage(bashMessage);
15673
+ if (target.destination) {
15674
+ try {
15675
+ this.#appendBashMessage(target.destination, message);
15676
+ } finally {
15677
+ void this.#releaseBashSessionTarget(target);
15678
+ }
15679
+ return;
14795
15680
  }
15681
+
15682
+ void this.#appendOwnedBashMessage(target, message).catch(error => {
15683
+ logger.error("Failed to record bash result in its owning session", { error: String(error) });
15684
+ });
14796
15685
  }
14797
15686
 
14798
15687
  /**
@@ -14814,22 +15703,14 @@ export class AgentSession {
14814
15703
  return this.#pendingBashMessages.length > 0;
14815
15704
  }
14816
15705
 
14817
- /**
14818
- * Flush pending bash messages to agent state and session.
14819
- * Called after agent turn completes to maintain proper message ordering.
14820
- */
14821
- #flushPendingBashMessages(): void {
15706
+ /** Flush pending bash messages after the active turn without changing their ownership. */
15707
+ async #flushPendingBashMessages(): Promise<void> {
14822
15708
  if (this.#pendingBashMessages.length === 0) return;
14823
-
14824
- for (const bashMessage of this.#pendingBashMessages) {
14825
- // Add to agent state
14826
- this.agent.appendMessage(bashMessage);
14827
-
14828
- // Save to session
14829
- this.sessionManager.appendMessage(bashMessage);
14830
- }
14831
-
15709
+ const pending = this.#pendingBashMessages;
14832
15710
  this.#pendingBashMessages = [];
15711
+ for (const { target, message } of pending) {
15712
+ await this.#appendOwnedBashMessage(target, message);
15713
+ }
14833
15714
  }
14834
15715
 
14835
15716
  // =========================================================================
@@ -15422,9 +16303,11 @@ export class AgentSession {
15422
16303
  this.#disconnectFromAgent();
15423
16304
  await this.abort({ goalReason: "internal" });
15424
16305
 
16306
+ await this.#flushPendingBashMessages();
15425
16307
  // Flush pending writes before switching so restore snapshots reflect committed state.
15426
16308
  await this.sessionManager.flush();
15427
16309
  const previousSessionState = this.sessionManager.captureState();
16310
+ const bashTransition = this.#beginBashSessionTransition();
15428
16311
  // Only same-session reloads compare against the prior context to detect
15429
16312
  // rollback edits (`#didSessionMessagesChange` below). Building it for a
15430
16313
  // different-session switch is a pure waste — and on huge pre-fix sessions
@@ -15469,6 +16352,7 @@ export class AgentSession {
15469
16352
 
15470
16353
  try {
15471
16354
  await this.sessionManager.setSessionFile(sessionPath);
16355
+ this.#markBashSessionTransition(bashTransition);
15472
16356
  if (switchingToDifferentSession) {
15473
16357
  this.#freshProviderSessionId = undefined;
15474
16358
  this.#clearInheritedProviderPromptCacheKey();
@@ -15602,6 +16486,7 @@ export class AgentSession {
15602
16486
  error: String(error),
15603
16487
  });
15604
16488
  }
16489
+ this.#finishBashSessionTransition(bashTransition, true);
15605
16490
  return true;
15606
16491
  } catch (error) {
15607
16492
  this.sessionManager.restoreState(previousSessionState);
@@ -15633,6 +16518,7 @@ export class AgentSession {
15633
16518
  this.#syncTodoPhasesFromBranch();
15634
16519
  this.#resetAllAdvisorRuntimes();
15635
16520
  this.#reconnectToAgent();
16521
+ this.#finishBashSessionTransition(bashTransition, false);
15636
16522
  throw error;
15637
16523
  }
15638
16524
  }
@@ -15678,14 +16564,25 @@ export class AgentSession {
15678
16564
  this.#pendingNextTurnMessages = [];
15679
16565
  this.#scheduledHiddenNextTurnGeneration = undefined;
15680
16566
 
16567
+ await this.#flushPendingBashMessages();
15681
16568
  // Flush pending writes before branching
15682
16569
  await this.sessionManager.flush();
16570
+ const bashTransition = this.#beginBashSessionTransition();
15683
16571
  this.#cancelOwnAsyncJobs();
16572
+ this.#abortAutolearnCapture();
16573
+ await this.#drainAutolearnCapture();
15684
16574
 
15685
- if (!selectedEntry.parentId) {
15686
- await this.sessionManager.newSession({ parentSession: previousSessionFile });
15687
- } else {
15688
- this.sessionManager.createBranchedSession(selectedEntry.parentId);
16575
+ let sessionTransitioned = false;
16576
+ try {
16577
+ if (!selectedEntry.parentId) {
16578
+ await this.sessionManager.newSession({ parentSession: previousSessionFile });
16579
+ } else {
16580
+ this.sessionManager.createBranchedSession(selectedEntry.parentId);
16581
+ }
16582
+ this.#markBashSessionTransition(bashTransition);
16583
+ sessionTransitioned = true;
16584
+ } finally {
16585
+ this.#finishBashSessionTransition(bashTransition, sessionTransitioned);
15689
16586
  }
15690
16587
  this.#rehydrateCheckpointRewindState();
15691
16588
  this.#syncTodoPhasesFromBranch();
@@ -15769,10 +16666,21 @@ export class AgentSession {
15769
16666
  await this.abort({ goalReason: "internal", reason: "branching /btw" });
15770
16667
  this.agent.replaceQueues([], []);
15771
16668
  }
16669
+ await this.#flushPendingBashMessages();
15772
16670
  await this.sessionManager.flush();
16671
+ const bashTransition = this.#beginBashSessionTransition();
15773
16672
  this.#cancelOwnAsyncJobs();
16673
+ this.#abortAutolearnCapture();
16674
+ await this.#drainAutolearnCapture();
15774
16675
 
15775
- this.sessionManager.createBranchedSession(leafId);
16676
+ let sessionTransitioned = false;
16677
+ try {
16678
+ this.sessionManager.createBranchedSession(leafId);
16679
+ this.#markBashSessionTransition(bashTransition);
16680
+ sessionTransitioned = true;
16681
+ } finally {
16682
+ this.#finishBashSessionTransition(bashTransition, sessionTransitioned);
16683
+ }
15776
16684
 
15777
16685
  this.#rehydrateCheckpointRewindState();
15778
16686
  this.sessionManager.appendMessage({
@@ -15828,6 +16736,7 @@ export class AgentSession {
15828
16736
  /** Raw session context built during navigation — pass to renderInitialMessages to skip a second O(N) walk. */
15829
16737
  sessionContext?: SessionContext;
15830
16738
  }> {
16739
+ await this.#flushPendingBashMessages();
15831
16740
  const oldLeafId = this.sessionManager.getLeafId();
15832
16741
 
15833
16742
  // No-op if already at target
@@ -15955,18 +16864,28 @@ export class AgentSession {
15955
16864
 
15956
16865
  // Switch leaf (with or without summary)
15957
16866
  // Summary is attached at the navigation target position (newLeafId), not the old branch
16867
+ const bashTransition = this.#beginBashSessionTransition();
15958
16868
  let summaryEntry: BranchSummaryEntry | undefined;
15959
- if (summaryText) {
15960
- // Create summary at target position (can be null for root)
15961
- const summaryId = this.sessionManager.branchWithSummary(newLeafId, summaryText, summaryDetails, fromExtension);
15962
-
15963
- summaryEntry = this.sessionManager.getEntry(summaryId) as BranchSummaryEntry;
15964
- } else if (newLeafId === null) {
15965
- // No summary, navigating to root - reset leaf
15966
- this.sessionManager.resetLeaf();
15967
- } else {
15968
- // No summary, navigating to non-root
15969
- this.sessionManager.branch(newLeafId);
16869
+ let branchTransitioned = false;
16870
+ try {
16871
+ if (summaryText) {
16872
+ // Create summary at target position (can be null for root)
16873
+ const summaryId = this.sessionManager.branchWithSummary(
16874
+ newLeafId,
16875
+ summaryText,
16876
+ summaryDetails,
16877
+ fromExtension,
16878
+ );
16879
+ summaryEntry = this.sessionManager.getEntry(summaryId) as BranchSummaryEntry;
16880
+ } else if (newLeafId === null) {
16881
+ this.sessionManager.resetLeaf();
16882
+ } else {
16883
+ this.sessionManager.branch(newLeafId);
16884
+ }
16885
+ this.#markBashSessionTransition(bashTransition);
16886
+ branchTransitioned = true;
16887
+ } finally {
16888
+ this.#finishBashSessionTransition(bashTransition, branchTransitioned);
15970
16889
  }
15971
16890
 
15972
16891
  // Update agent state — build display context to populate agent messages.
@@ -16709,29 +17628,73 @@ export class AgentSession {
16709
17628
  return this.#advisors[0]?.agent;
16710
17629
  }
16711
17630
 
17631
+ /**
17632
+ * Lightweight advisor status for the status line: returns just the configured
17633
+ * flag and per-advisor name/status without computing token/cost breakdowns.
17634
+ * Avoids re-tokenizing the advisor transcript on every render frame.
17635
+ */
17636
+ getAdvisorStatusOverview(): { configured: boolean; advisors: { name: string; status: AdvisorRuntimeStatus }[] } {
17637
+ // Override stale map entries with live runtime status: failureNotified/quotaExhausted
17638
+ // clear on reset() but #advisorStatuses lags until the next build.
17639
+ const liveStatusBySlug = new Map<string, AdvisorRuntimeStatus>();
17640
+ for (const a of this.#advisors) {
17641
+ liveStatusBySlug.set(
17642
+ a.slug,
17643
+ a.runtime.quotaExhausted ? "quota_exhausted" : a.runtime.failureNotified ? "error" : "running",
17644
+ );
17645
+ }
17646
+ const advisors = [...this.#advisorStatuses.entries()].map(([slug, { name, status }]) => ({
17647
+ name,
17648
+ status: liveStatusBySlug.get(slug) ?? status,
17649
+ }));
17650
+ return { configured: this.#advisorEnabled, advisors };
17651
+ }
16712
17652
  /**
16713
17653
  * Return structured advisor stats for the status command and TUI panel.
16714
17654
  */
16715
17655
  getAdvisorStats(): AdvisorStats {
16716
17656
  const configured = this.#advisorEnabled;
16717
- const advisors = this.#advisors.map(a => this.#computeAdvisorStat(a));
16718
- if (advisors.length === 0) {
17657
+ const liveAdvisors = this.#advisors.map(a => this.#computeAdvisorStat(a));
17658
+ // Build the complete roster from #advisorStatuses, which already has the
17659
+ // correct de-duped slugs as keys. Live advisors (from #advisors) carry full
17660
+ // token/cost data; disabled/no-model/quota-exhausted advisors appear as
17661
+ // skeleton entries with just name + status so the status line renders a dot.
17662
+ const liveStatBySlug = new Map(this.#advisors.map((a, i) => [a.slug, liveAdvisors[i]]));
17663
+ const roster: PerAdvisorStat[] = [];
17664
+ for (const [slug, entry] of this.#advisorStatuses) {
17665
+ const live = liveStatBySlug.get(slug);
17666
+ if (live) {
17667
+ roster.push(live);
17668
+ } else {
17669
+ roster.push({
17670
+ name: entry.name,
17671
+ status: entry.status,
17672
+ contextWindow: 0,
17673
+ contextTokens: 0,
17674
+ tokens: { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
17675
+ cost: 0,
17676
+ messages: { user: 0, assistant: 0, total: 0 },
17677
+ });
17678
+ }
17679
+ }
17680
+ const active = liveAdvisors.length > 0;
17681
+ if (liveAdvisors.length === 0) {
16719
17682
  return {
16720
17683
  configured,
16721
- active: false,
17684
+ active,
16722
17685
  contextWindow: 0,
16723
17686
  contextTokens: 0,
16724
17687
  tokens: { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
16725
17688
  cost: 0,
16726
17689
  messages: { user: 0, assistant: 0, total: 0 },
16727
- advisors: [],
17690
+ advisors: roster,
16728
17691
  };
16729
17692
  }
16730
17693
  const tokens = { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0, total: 0 };
16731
17694
  const messages = { user: 0, assistant: 0, total: 0 };
16732
17695
  let cost = 0;
16733
17696
  let contextTokens = 0;
16734
- for (const a of advisors) {
17697
+ for (const a of liveAdvisors) {
16735
17698
  tokens.input += a.tokens.input;
16736
17699
  tokens.output += a.tokens.output;
16737
17700
  tokens.reasoning += a.tokens.reasoning;
@@ -16748,14 +17711,14 @@ export class AgentSession {
16748
17711
  // first advisor's so the legacy status line stays byte-identical.
16749
17712
  return {
16750
17713
  configured,
16751
- active: true,
16752
- model: advisors[0].model,
16753
- contextWindow: advisors[0].contextWindow,
17714
+ active,
17715
+ model: liveAdvisors[0].model,
17716
+ contextWindow: liveAdvisors[0].contextWindow,
16754
17717
  contextTokens,
16755
17718
  tokens,
16756
17719
  cost,
16757
17720
  messages,
16758
- advisors,
17721
+ advisors: roster,
16759
17722
  };
16760
17723
  }
16761
17724
 
@@ -16789,12 +17752,18 @@ export class AgentSession {
16789
17752
  }
16790
17753
  return {
16791
17754
  name: advisor.name,
17755
+ status: advisor.runtime.quotaExhausted
17756
+ ? "quota_exhausted"
17757
+ : advisor.runtime.failureNotified
17758
+ ? "error"
17759
+ : "running",
16792
17760
  model,
16793
17761
  contextWindow: model.contextWindow ?? 0,
16794
17762
  contextTokens,
16795
17763
  tokens: { input, output, reasoning, cacheRead, cacheWrite, total: totalTokens },
16796
17764
  cost,
16797
17765
  messages: { user, assistant, total: messages.length },
17766
+ sessionId: advisor.agent.sessionId,
16798
17767
  };
16799
17768
  }
16800
17769
 
@@ -16803,13 +17772,18 @@ export class AgentSession {
16803
17772
  */
16804
17773
  formatAdvisorStatus(): string {
16805
17774
  const stats = this.getAdvisorStats();
16806
- if (!stats.active) {
17775
+ if (!stats.active && stats.advisors.length === 0) {
16807
17776
  return stats.configured
16808
17777
  ? "Advisor setting is enabled, but no model is assigned to the 'advisor' role."
16809
17778
  : "Advisor is disabled.";
16810
17779
  }
16811
17780
  if (stats.advisors.length <= 1) {
16812
17781
  const s = stats.advisors[0];
17782
+ if (s && s.status === "no_model") {
17783
+ return stats.configured
17784
+ ? "Advisor setting is enabled, but no model is assigned to the 'advisor' role."
17785
+ : "Advisor is disabled.";
17786
+ }
16813
17787
  const contextLine =
16814
17788
  s.contextWindow > 0
16815
17789
  ? `Context: ${s.contextTokens.toLocaleString()} / ${s.contextWindow.toLocaleString()} tokens (${Math.round((s.contextTokens / s.contextWindow) * 100)}%)`
@@ -16818,6 +17792,7 @@ export class AgentSession {
16818
17792
  if (s.tokens.cacheRead > 0) spendParts.push(`${s.tokens.cacheRead.toLocaleString()} cache read`);
16819
17793
  if (s.tokens.cacheWrite > 0) spendParts.push(`${s.tokens.cacheWrite.toLocaleString()} cache write`);
16820
17794
  const spendLine = `Spend: ${spendParts.join(", ")}, $${s.cost.toFixed(4)}`;
17795
+ if (!s.model || s.status !== "running") return `Advisor "${s.name}" is ${s.status.replace("_", " ")}.`;
16821
17796
  return `Advisor is enabled (${s.model.provider}/${s.model.id}). ${contextLine}. ${spendLine}.`;
16822
17797
  }
16823
17798
  const lines = [`Advisors enabled (${stats.advisors.length}):`];
@@ -16826,7 +17801,9 @@ export class AgentSession {
16826
17801
  s.contextWindow > 0
16827
17802
  ? `${s.contextTokens.toLocaleString()} / ${s.contextWindow.toLocaleString()} (${Math.round((s.contextTokens / s.contextWindow) * 100)}%)`
16828
17803
  : `${s.contextTokens.toLocaleString()}`;
16829
- lines.push(` • ${s.name} (${s.model.provider}/${s.model.id}) — context ${ctx} tokens, $${s.cost.toFixed(4)}`);
17804
+ lines.push(
17805
+ ` • ${s.name}${s.model && s.status === "running" ? ` (${s.model.provider}/${s.model.id})` : ` [${s.status}]`} — context ${ctx} tokens, $${s.cost.toFixed(4)}`,
17806
+ );
16830
17807
  }
16831
17808
  lines.push(
16832
17809
  `Totals: ${stats.tokens.input.toLocaleString()} input, ${stats.tokens.output.toLocaleString()} output, $${stats.cost.toFixed(4)}.`,
@@ -16835,37 +17812,51 @@ export class AgentSession {
16835
17812
  }
16836
17813
 
16837
17814
  /**
16838
- * Estimate the advisor's current context tokens. When the advisor has a
16839
- * recent non-aborted assistant message with usage, use that prompt's token
16840
- * count and add a trailing estimate for messages after it. Otherwise estimate
16841
- * every message.
17815
+ * Estimate the advisor's current context tokens. A successful provider usage
17816
+ * after the latest advisor compaction is ground truth for the prompt plus its
17817
+ * generated output; only messages after that anchor are estimated. Usage from
17818
+ * retained pre-compaction messages is stale and must not immediately retrigger
17819
+ * maintenance on the newly compacted context.
16842
17820
  */
16843
17821
  #estimateAdvisorContextTokens(messages: AgentMessage[]): number {
16844
- let lastUsageIndex: number | null = null;
16845
- let lastUsage: AssistantMessage["usage"] | undefined;
17822
+ let usageAnchorStartIndex = 0;
16846
17823
  for (let i = messages.length - 1; i >= 0; i--) {
16847
- const msg = messages[i];
16848
- if (msg.role === "assistant") {
16849
- const assistantMsg = msg as AssistantMessage;
16850
- if (assistantMsg.stopReason !== "aborted" && assistantMsg.stopReason !== "error" && assistantMsg.usage) {
16851
- lastUsage = assistantMsg.usage;
16852
- lastUsageIndex = i;
16853
- break;
16854
- }
17824
+ const message = messages[i];
17825
+ if (message.role !== "compactionSummary") continue;
17826
+ const advisorSummary = message as AdvisorCompactionSummaryMessage;
17827
+ // Advisor summaries created before this runtime-only boundary existed have
17828
+ // no trustworthy way to distinguish retained from newly appended messages.
17829
+ // Conservatively ignore every current assistant until the next compaction.
17830
+ usageAnchorStartIndex = advisorSummary.advisorUsageAnchorStartIndex ?? messages.length;
17831
+ break;
17832
+ }
17833
+
17834
+ let lastUsageIndex: number | undefined;
17835
+ let lastUsage: AssistantMessage["usage"] | undefined;
17836
+ for (let i = messages.length - 1; i >= usageAnchorStartIndex; i--) {
17837
+ const message = messages[i];
17838
+ if (message.role !== "assistant") continue;
17839
+ const assistant = message as AssistantMessage;
17840
+ if (assistant.stopReason !== "aborted" && assistant.stopReason !== "error" && assistant.usage) {
17841
+ lastUsage = assistant.usage;
17842
+ lastUsageIndex = i;
17843
+ break;
16855
17844
  }
16856
17845
  }
16857
- if (!lastUsage || lastUsageIndex === null) {
17846
+
17847
+ const estimateOptions = { excludeEncryptedReasoning: true } as const;
17848
+ if (!lastUsage || lastUsageIndex === undefined) {
16858
17849
  let estimated = 0;
16859
17850
  for (const message of messages) {
16860
- estimated += estimateTokens(message);
17851
+ estimated += estimateTokens(message, estimateOptions);
16861
17852
  }
16862
17853
  return estimated;
16863
17854
  }
16864
17855
  let trailingTokens = 0;
16865
17856
  for (let i = lastUsageIndex + 1; i < messages.length; i++) {
16866
- trailingTokens += estimateTokens(messages[i]);
17857
+ trailingTokens += estimateTokens(messages[i], estimateOptions);
16867
17858
  }
16868
- return calculatePromptTokens(lastUsage) + trailingTokens;
17859
+ return calculateContextTokens(lastUsage) + trailingTokens;
16869
17860
  }
16870
17861
 
16871
17862
  /**