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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/CHANGELOG.md +92 -20
  2. package/dist/cli.js +3485 -3448
  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 +32 -7
  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/exec/bash-executor.d.ts +2 -0
  13. package/dist/types/extensibility/extensions/managed-timers.d.ts +15 -0
  14. package/dist/types/extensibility/extensions/runner.d.ts +7 -0
  15. package/dist/types/extensibility/extensions/types.d.ts +18 -0
  16. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +40 -0
  17. package/dist/types/extensibility/shared-events.d.ts +6 -0
  18. package/dist/types/extensibility/utils.d.ts +2 -2
  19. package/dist/types/mcp/transports/stdio.d.ts +13 -1
  20. package/dist/types/modes/components/advisor-config.d.ts +8 -1
  21. package/dist/types/modes/components/hook-editor.d.ts +7 -0
  22. package/dist/types/modes/components/model-hub.d.ts +5 -2
  23. package/dist/types/modes/components/session-selector.d.ts +2 -0
  24. package/dist/types/modes/controllers/command-controller.d.ts +8 -0
  25. package/dist/types/modes/controllers/selector-controller.d.ts +3 -1
  26. package/dist/types/modes/print-mode.d.ts +1 -1
  27. package/dist/types/modes/warp-events.d.ts +24 -0
  28. package/dist/types/modes/warp-events.test.d.ts +1 -0
  29. package/dist/types/plan-mode/model-transition.d.ts +47 -0
  30. package/dist/types/plan-mode/model-transition.test.d.ts +1 -0
  31. package/dist/types/registry/agent-lifecycle.d.ts +26 -1
  32. package/dist/types/sdk.d.ts +13 -2
  33. package/dist/types/session/agent-session.d.ts +34 -10
  34. package/dist/types/session/session-history-format.d.ts +10 -0
  35. package/dist/types/session/session-manager.d.ts +14 -0
  36. package/dist/types/slash-commands/helpers/active-oauth-account.d.ts +9 -0
  37. package/dist/types/task/label.d.ts +1 -1
  38. package/dist/types/telemetry-export.d.ts +34 -9
  39. package/dist/types/tools/approval.d.ts +8 -0
  40. package/dist/types/tools/bash.d.ts +2 -0
  41. package/dist/types/tools/essential-tools.d.ts +29 -0
  42. package/dist/types/tools/image-gen.d.ts +2 -1
  43. package/dist/types/tools/index.d.ts +1 -0
  44. package/dist/types/tools/xdev.d.ts +11 -2
  45. package/dist/types/utils/title-generator.d.ts +2 -1
  46. package/dist/types/web/search/providers/kimi.d.ts +4 -1
  47. package/dist/types/web/search/types.d.ts +1 -1
  48. package/package.json +21 -16
  49. package/src/advisor/__tests__/advisor.test.ts +1304 -42
  50. package/src/advisor/__tests__/config.test.ts +58 -2
  51. package/src/advisor/config.ts +76 -24
  52. package/src/advisor/runtime.ts +445 -92
  53. package/src/autolearn/controller.ts +23 -28
  54. package/src/cli.ts +5 -1
  55. package/src/config/model-discovery.ts +81 -21
  56. package/src/config/model-registry.ts +25 -6
  57. package/src/config/model-resolver.ts +14 -7
  58. package/src/config/settings-schema.ts +42 -6
  59. package/src/config/settings.ts +405 -25
  60. package/src/cursor.ts +20 -3
  61. package/src/debug/report-bundle.ts +40 -4
  62. package/src/discovery/helpers.ts +28 -5
  63. package/src/exec/bash-executor.ts +14 -5
  64. package/src/extensibility/custom-tools/loader.ts +3 -3
  65. package/src/extensibility/custom-tools/wrapper.ts +2 -1
  66. package/src/extensibility/extensions/loader.ts +3 -3
  67. package/src/extensibility/extensions/managed-timers.ts +83 -0
  68. package/src/extensibility/extensions/runner.ts +26 -0
  69. package/src/extensibility/extensions/types.ts +18 -0
  70. package/src/extensibility/extensions/wrapper.ts +2 -1
  71. package/src/extensibility/hooks/loader.ts +3 -3
  72. package/src/extensibility/legacy-pi-coding-agent-shim.ts +96 -1
  73. package/src/extensibility/plugins/legacy-pi-compat.ts +225 -22
  74. package/src/extensibility/plugins/manager.ts +2 -2
  75. package/src/extensibility/shared-events.ts +6 -0
  76. package/src/extensibility/utils.ts +91 -25
  77. package/src/irc/bus.ts +22 -3
  78. package/src/launch/broker.ts +3 -2
  79. package/src/launch/client.ts +2 -2
  80. package/src/launch/presence.ts +2 -2
  81. package/src/lsp/client.ts +1 -1
  82. package/src/main.ts +11 -8
  83. package/src/mcp/manager.ts +9 -3
  84. package/src/mcp/transports/stdio.ts +103 -23
  85. package/src/modes/components/advisor-config.ts +65 -3
  86. package/src/modes/components/ask-dialog.ts +1 -1
  87. package/src/modes/components/hook-editor.ts +18 -3
  88. package/src/modes/components/model-hub.ts +138 -42
  89. package/src/modes/components/session-selector.ts +4 -0
  90. package/src/modes/components/status-line/component.test.ts +1 -0
  91. package/src/modes/components/status-line/segments.ts +21 -6
  92. package/src/modes/controllers/command-controller.ts +167 -47
  93. package/src/modes/controllers/event-controller.ts +5 -0
  94. package/src/modes/controllers/extension-ui-controller.ts +4 -22
  95. package/src/modes/controllers/input-controller.ts +12 -12
  96. package/src/modes/controllers/selector-controller.ts +191 -31
  97. package/src/modes/interactive-mode.ts +139 -54
  98. package/src/modes/print-mode.ts +3 -3
  99. package/src/modes/rpc/host-tools.ts +2 -1
  100. package/src/modes/rpc/rpc-mode.ts +19 -4
  101. package/src/modes/warp-events.test.ts +794 -0
  102. package/src/modes/warp-events.ts +232 -0
  103. package/src/plan-mode/model-transition.test.ts +60 -0
  104. package/src/plan-mode/model-transition.ts +51 -0
  105. package/src/registry/agent-lifecycle.ts +133 -18
  106. package/src/sdk.ts +221 -42
  107. package/src/session/agent-session.ts +1285 -348
  108. package/src/session/session-history-format.ts +20 -5
  109. package/src/session/session-manager.ts +48 -0
  110. package/src/slash-commands/builtin-registry.ts +7 -0
  111. package/src/slash-commands/helpers/active-oauth-account.ts +16 -0
  112. package/src/task/executor.ts +1 -1
  113. package/src/task/label.ts +2 -0
  114. package/src/telemetry-export.ts +453 -97
  115. package/src/tools/approval.ts +11 -0
  116. package/src/tools/bash.ts +71 -38
  117. package/src/tools/essential-tools.ts +45 -0
  118. package/src/tools/gh.ts +169 -2
  119. package/src/tools/image-gen.ts +69 -7
  120. package/src/tools/index.ts +7 -5
  121. package/src/tools/read.ts +48 -3
  122. package/src/tools/write.ts +22 -4
  123. package/src/tools/xdev.ts +14 -3
  124. package/src/utils/title-generator.ts +15 -4
  125. package/src/web/search/providers/kimi.ts +18 -12
  126. 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
  /**
@@ -2431,12 +2563,17 @@ export class AgentSession {
2431
2563
  readPlan: url => this.#readPlanYoloFile(url),
2432
2564
  listPlanFiles: () => this.#listPlanYoloFiles(),
2433
2565
  });
2566
+ this.setPlanModeState(undefined);
2434
2567
  const previousTools = this.#planYoloPreviousTools;
2435
- if (previousTools) {
2436
- await this.setActiveToolsByName(previousTools);
2568
+ try {
2569
+ if (previousTools) {
2570
+ await this.setActiveToolsByName(previousTools);
2571
+ }
2572
+ } catch (error) {
2573
+ this.setPlanModeState(state);
2574
+ throw error;
2437
2575
  }
2438
2576
  this.setPlanProposalHandler(null);
2439
- this.setPlanModeState(undefined);
2440
2577
  this.#planYolo = undefined;
2441
2578
  this.#planYoloPreviousTools = undefined;
2442
2579
  await this.setModelTemporary(planYolo.target, planYolo.thinkingLevel, { ephemeral: true });
@@ -2494,6 +2631,11 @@ export class AgentSession {
2494
2631
  constructor(config: AgentSessionConfig) {
2495
2632
  this.agent = config.agent;
2496
2633
  this.sessionManager = config.sessionManager;
2634
+ this.#bashSessionTarget = {
2635
+ sessionId: this.sessionManager.getSessionId(),
2636
+ refs: 0,
2637
+ destination: { kind: "current", manager: this.sessionManager },
2638
+ };
2497
2639
  this.settings = config.settings;
2498
2640
  this.#autoApprove = config.autoApprove === true;
2499
2641
  // Power assertions are taken per turn (see #beginInFlight); nothing acquired here.
@@ -2544,7 +2686,8 @@ export class AgentSession {
2544
2686
  this.#toolRegistry = config.toolRegistry ?? new Map();
2545
2687
  this.#createVibeTools = config.createVibeTools;
2546
2688
  this.#builtInToolNames = new Set(config.builtInToolNames ?? []);
2547
- this.#requestedToolNames = config.requestedToolNames;
2689
+ this.#presentationPinnedToolNames = config.presentationPinnedToolNames;
2690
+ this.#ensureWriteRegistered = config.ensureWriteRegistered;
2548
2691
  this.#transformContext = config.transformContext ?? (messages => messages);
2549
2692
  this.#transformProviderContext = config.transformProviderContext;
2550
2693
  this.#sideStreamFn = config.sideStreamFn ?? streamSimple;
@@ -2596,7 +2739,18 @@ export class AgentSession {
2596
2739
  this.#advisorPrimaryTurnsCompleted++;
2597
2740
  if (this.#advisors.length > 0) {
2598
2741
  for (const a of this.#advisors) {
2599
- if (!a.runtime.disposed) a.runtime.onTurnEnd(messages, { willContinue: context?.willContinue });
2742
+ if (a.runtime.disposed) continue;
2743
+ try {
2744
+ a.runtime.onTurnEnd(messages, { willContinue: context?.willContinue });
2745
+ } catch (advisorErr) {
2746
+ // CRITICAL boundary: NOTHING an advisor does may abort the
2747
+ // primary agent's turn-end. A throwing advisor loses its
2748
+ // delta; the primary continues untouched.
2749
+ logger.warn("advisor onTurnEnd threw; delta dropped", {
2750
+ advisor: a.name,
2751
+ err: String(advisorErr),
2752
+ });
2753
+ }
2600
2754
  }
2601
2755
  const syncBacklog = this.settings.get("advisor.syncBacklog");
2602
2756
  if (syncBacklog !== "off") {
@@ -2805,6 +2959,12 @@ export class AgentSession {
2805
2959
  slug = candidate;
2806
2960
  usedSlugs.add(slug);
2807
2961
  }
2962
+ // Per-advisor toggle: skip disabled advisors but keep them in the
2963
+ // status map so they show `○` rather than disappearing.
2964
+ if (config.enabled === false) {
2965
+ this.#advisorStatuses.set(slug, { name: config.name, status: "paused" });
2966
+ continue;
2967
+ }
2808
2968
 
2809
2969
  // Resolve the advisor's model: an explicit `model` override wins; else the
2810
2970
  // `advisor` role chain. A model that fails to resolve skips just this advisor.
@@ -2815,6 +2975,7 @@ export class AgentSession {
2815
2975
  model = resolved.model;
2816
2976
  thinkingLevel = concreteThinkingLevel(resolved.thinkingLevel);
2817
2977
  if (!model) {
2978
+ this.#advisorStatuses.set(slug, { name: config.name, status: "no_model" });
2818
2979
  if (emitWarnings) {
2819
2980
  this.emitNotice("warning", `Advisor "${config.name}": no model matched "${config.model}"`, "advisor");
2820
2981
  }
@@ -2823,6 +2984,7 @@ export class AgentSession {
2823
2984
  } else {
2824
2985
  const sel = resolveAdvisorRoleSelection(this.settings, this.#modelRegistry.getAvailable());
2825
2986
  if (!sel) {
2987
+ this.#advisorStatuses.set(slug, { name: config.name, status: "no_model" });
2826
2988
  if (emitWarnings) {
2827
2989
  logger.debug("advisor enabled but no model assigned to the 'advisor' role; advisor inactive", {
2828
2990
  advisor: config.name,
@@ -2846,6 +3008,11 @@ export class AgentSession {
2846
3008
  const requestedLevel = thinkingLevel ?? ThinkingLevel.Medium;
2847
3009
  const resolvedLevel = resolveThinkingLevelForModel(model, requestedLevel);
2848
3010
  const advisorThinkingLevel: ThinkingLevel = resolvedLevel ?? ThinkingLevel.Inherit;
3011
+ // Record the status entry now (in roster order) so the Map's insertion
3012
+ // order matches the configured roster even when earlier advisors were
3013
+ // skipped as paused/no_model. The build loop overwrites this to "running"
3014
+ // without changing insertion order.
3015
+ this.#advisorStatuses.set(slug, { name: config.name, status: "running" });
2849
3016
  descriptors.push({
2850
3017
  config,
2851
3018
  name: config.name,
@@ -2881,6 +3048,11 @@ export class AgentSession {
2881
3048
  if (!this.#advisorEnabled) return false;
2882
3049
  if (this.#agentKind !== "main" && !this.settings.get("advisor.subagents")) return false;
2883
3050
 
3051
+ // Rebuild the status map from scratch so removed/renamed advisors don't
3052
+ // leave stale entries. #resolveAdvisorRuntimeDescriptors populates every
3053
+ // entry (`paused`/`no_model`/`running`) in roster order; the build loop
3054
+ // below confirms `running` for successfully built advisors.
3055
+ this.#advisorStatuses.clear();
2884
3056
  const descriptors = this.#resolveAdvisorRuntimeDescriptors(true);
2885
3057
 
2886
3058
  // Advisor service tier (`tier.advisor`): "none" (default) runs the advisor
@@ -2921,11 +3093,16 @@ export class AgentSession {
2921
3093
 
2922
3094
  const names = config.tools === undefined ? ADVISOR_DEFAULT_TOOL_NAMES : new Set(config.tools);
2923
3095
  const tools = (this.#advisorTools ?? []).filter(t => names.has(t.name));
3096
+ const advisorLoopTools: AgentTool<any>[] = [adviseTool, ...tools];
3097
+ const advisorToolMap = new Map<string, AgentTool>();
2924
3098
  const availableAdvisorToolNames = new Set<string>();
2925
- availableAdvisorToolNames.add(adviseTool.name);
2926
- for (const tool of tools) {
3099
+ for (const tool of advisorLoopTools) {
2927
3100
  availableAdvisorToolNames.add(tool.name);
2928
- if (tool.customWireName !== undefined) availableAdvisorToolNames.add(tool.customWireName);
3101
+ advisorToolMap.set(tool.name, tool);
3102
+ if (tool.customWireName !== undefined) {
3103
+ availableAdvisorToolNames.add(tool.customWireName);
3104
+ advisorToolMap.set(tool.customWireName, tool);
3105
+ }
2929
3106
  }
2930
3107
  let quarantinedAdvisorOutput: string | undefined;
2931
3108
  let currentAdvisorInput = "";
@@ -2965,17 +3142,36 @@ export class AgentSession {
2965
3142
  // Codex request identity remains UUID-shaped while local labels keep the
2966
3143
  // `-advisor` suffix.
2967
3144
  const advisorPromptCacheKey = this.agent.promptCacheKey ?? advisorProviderSessionId;
3145
+ // On the Cursor provider every tool runs server-side and is dispatched
3146
+ // back through `cursorExecHandlers`; without this bridge the advisor's
3147
+ // own tools (including the MCP `advise` tool) return `toolNotFound` and
3148
+ // no advice is ever routed (issue #5680). Mirrors the primary agent's
3149
+ // bridge (`sdk.ts`), scoped to this advisor's granted tool set.
3150
+ // Cursor's native `delete` frame removes files directly, bypassing the
3151
+ // tool map, so gate it on the advisor actually holding a file-mutating
3152
+ // tool. A default read-only advisor (advise/read/grep/glob) never gets
3153
+ // to delete workspace files it was never granted (issue #5680 review).
3154
+ const advisorCanMutateFiles = advisorToolMap.has("write") || advisorToolMap.has("edit");
3155
+ if (advisorCanMutateFiles) availableAdvisorToolNames.add("delete");
3156
+ const advisorCursorExecHandlers = new CursorExecHandlers({
3157
+ cwd: this.sessionManager.getCwd(),
3158
+ getCwd: () => this.sessionManager.getCwd(),
3159
+ tools: advisorToolMap,
3160
+ allowNativeDelete: advisorCanMutateFiles,
3161
+ });
2968
3162
  const advisorAgent = new Agent({
2969
3163
  initialState: {
2970
3164
  systemPrompt,
2971
3165
  model: advisorModel,
2972
3166
  thinkingLevel: toReasoningEffort(advisorThinkingLevel),
2973
- tools: [adviseTool, ...tools],
3167
+ tools: advisorLoopTools,
2974
3168
  },
2975
3169
  appendOnlyContext,
2976
3170
  sessionId: advisorProviderSessionId,
2977
3171
  promptCacheKey: advisorPromptCacheKey,
2978
3172
  providerSessionState: this.#providerSessionState,
3173
+ cursorExecHandlers: advisorCursorExecHandlers,
3174
+ cwdResolver: () => this.sessionManager.getCwd(),
2979
3175
  preferWebsockets: this.#preferWebsockets,
2980
3176
  getApiKey: requestModel => this.#modelRegistry.resolver(requestModel, advisorProviderSessionId),
2981
3177
  streamFn: this.#advisorStreamFn,
@@ -3046,33 +3242,30 @@ export class AgentSession {
3046
3242
  maintainContext: incomingTokens => this.#maintainAdvisorContext(advisorRef, incomingTokens),
3047
3243
  obfuscator: this.#obfuscator,
3048
3244
  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
- );
3245
+ onTurnError: (error, failedMessages) => this.#recoverAdvisorTurn(advisorRef, error, failedMessages),
3246
+ onTurnSuccess: async () => {
3247
+ const fallback = advisorRef.retryFallback;
3248
+ if (!advisorRef.retryFallbackPendingSuccess || !fallback) return;
3249
+ advisorRef.retryFallbackPendingSuccess = false;
3250
+ await this.#emitSessionEvent({
3251
+ type: "retry_fallback_succeeded",
3252
+ model: formatRetryFallbackSelector(advisorRef.agent.state.model, advisorRef.thinkingLevel),
3253
+ role: fallback.role,
3254
+ });
3067
3255
  },
3068
3256
  notifyFailure: error => {
3257
+ this.#advisorStatuses.set(slug, { name: advisorName, status: "error" });
3069
3258
  const message = error instanceof Error ? error.message : String(error);
3070
3259
  this.emitNotice(
3071
3260
  "warning",
3072
- `Advisor${slug ? ` "${advisorName}"` : ""} unavailable for ${formatModelString(advisorModel)}: ${message}`,
3261
+ `Advisor${slug ? ` "${advisorName}"` : ""} unavailable for ${formatModelString(advisorAgent.state.model)}: ${message}`,
3073
3262
  "advisor",
3074
3263
  );
3075
3264
  },
3265
+ notifyQuotaExhausted: () => {
3266
+ this.#advisorStatuses.set(slug, { name: advisorName, status: "quota_exhausted" });
3267
+ this.emitNotice("warning", `Advisor "${advisorName}" quota exhausted — pausing until reset.`, "advisor");
3268
+ },
3076
3269
  });
3077
3270
 
3078
3271
  const advisorRef: ActiveAdvisor = {
@@ -3086,10 +3279,13 @@ export class AgentSession {
3086
3279
  recorderClosed: Promise.resolve(),
3087
3280
  model: advisorModel,
3088
3281
  thinkingLevel: advisorThinkingLevel,
3282
+ providerSessionId: advisorProviderSessionId,
3283
+ retryFallbackPendingSuccess: false,
3089
3284
  signature,
3090
3285
  };
3091
3286
  this.#attachAdvisorRecorderFeed(advisorRef);
3092
3287
  if (seedToCurrent) runtime.seedTo(this.agent.state.messages.length);
3288
+ this.#advisorStatuses.set(slug, { name: advisorName, status: "running" });
3093
3289
  this.#advisors.push(advisorRef);
3094
3290
  }
3095
3291
 
@@ -3251,6 +3447,163 @@ export class AgentSession {
3251
3447
  });
3252
3448
  }
3253
3449
 
3450
+ /** Switch one advisor model while preserving its context and effort invariants. */
3451
+ #setAdvisorModel(advisor: ActiveAdvisor, model: Model, requestedThinkingLevel: ThinkingLevel): ThinkingLevel {
3452
+ const resolvedThinkingLevel = resolveThinkingLevelForModel(model, requestedThinkingLevel);
3453
+ const nextThinkingLevel = resolvedThinkingLevel ?? ThinkingLevel.Inherit;
3454
+ advisor.agent.setModel(model);
3455
+ advisor.agent.setThinkingLevel(toReasoningEffort(nextThinkingLevel));
3456
+ advisor.agent.setDisableReasoning(shouldDisableReasoning(nextThinkingLevel));
3457
+ advisor.agent.appendOnlyContext?.invalidateForModelChange();
3458
+ advisor.model = model;
3459
+ advisor.thinkingLevel = nextThinkingLevel;
3460
+ return nextThinkingLevel;
3461
+ }
3462
+
3463
+ /** Restore an advisor's configured primary once its fallback cooldown expires. */
3464
+ async #maybeRestoreAdvisorRetryFallbackPrimary(advisor: ActiveAdvisor): Promise<void> {
3465
+ const fallback = advisor.retryFallback;
3466
+ if (!fallback || this.#getRetryFallbackRevertPolicy() !== "cooldown-expiry") return;
3467
+
3468
+ const originalSelector = parseRetryFallbackSelector(fallback.originalSelector, this.#modelRegistry);
3469
+ if (!originalSelector) {
3470
+ advisor.retryFallback = undefined;
3471
+ advisor.retryFallbackPendingSuccess = false;
3472
+ return;
3473
+ }
3474
+ const currentSelector = formatRetryFallbackSelector(advisor.agent.state.model, advisor.thinkingLevel);
3475
+ if (currentSelector === originalSelector.raw) {
3476
+ if (!this.#isRetryFallbackSelectorSuppressed(originalSelector)) {
3477
+ advisor.retryFallback = undefined;
3478
+ advisor.retryFallbackPendingSuccess = false;
3479
+ }
3480
+ return;
3481
+ }
3482
+ if (this.#isRetryFallbackSelectorSuppressed(originalSelector)) return;
3483
+
3484
+ const resolvedPrimary = resolveModelOverride([originalSelector.raw], this.#modelRegistry, this.settings);
3485
+ const primaryModel =
3486
+ resolvedPrimary.model ?? this.#modelRegistry.find(originalSelector.provider, originalSelector.id);
3487
+ if (!primaryModel) return;
3488
+ const apiKey = await this.#modelRegistry.getApiKey(primaryModel, advisor.providerSessionId);
3489
+ if (!apiKey) return;
3490
+
3491
+ const thinkingToApply =
3492
+ advisor.thinkingLevel === fallback.lastAppliedThinkingLevel
3493
+ ? fallback.originalThinkingLevel
3494
+ : advisor.thinkingLevel;
3495
+ this.#setAdvisorModel(advisor, primaryModel, thinkingToApply);
3496
+ this.settings.getStorage()?.recordModelUsage(formatModelStringWithRouting(primaryModel));
3497
+ advisor.retryFallback = undefined;
3498
+ advisor.retryFallbackPendingSuccess = false;
3499
+ }
3500
+
3501
+ /**
3502
+ * Apply the advisor's configured provider-failure fallback chain after
3503
+ * same-provider credential rotation has no usable sibling.
3504
+ */
3505
+ async #recoverAdvisorTurn(
3506
+ advisor: ActiveAdvisor,
3507
+ error: unknown,
3508
+ failedMessages: readonly AgentMessage[],
3509
+ ): Promise<boolean> {
3510
+ if (error instanceof AdvisorOutputQuarantinedError) return false;
3511
+
3512
+ const failedMessage = failedMessages.findLast(
3513
+ (message): message is AssistantMessage => message.role === "assistant",
3514
+ );
3515
+ if (failedMessage?.stopReason !== "error") {
3516
+ // Stream setup can reject before any assistant turn is recorded (e.g.
3517
+ // an HTTP 429 thrown from prompt()); classify the raw error so a
3518
+ // structural usage limit still marks the exhausted credential.
3519
+ const message = error instanceof Error ? error.message : String(error);
3520
+ if (!AIError.isUsageLimit(error) && !isUsageLimitOutcome(extractHttpStatusFromError(error), message)) {
3521
+ return false;
3522
+ }
3523
+ const currentModel = advisor.agent.state.model;
3524
+ const outcome = await this.#modelRegistry.authStorage.markUsageLimitReached(
3525
+ currentModel.provider,
3526
+ advisor.providerSessionId,
3527
+ {
3528
+ retryAfterMs: extractRetryHint(undefined, message),
3529
+ baseUrl: currentModel.baseUrl,
3530
+ modelId: currentModel.id,
3531
+ },
3532
+ );
3533
+ return outcome.switched;
3534
+ }
3535
+ if (failedMessage.content.some(block => block.type === "toolCall")) return false;
3536
+
3537
+ const currentModel = advisor.agent.state.model;
3538
+ const message = failedMessage.errorMessage ?? (error instanceof Error ? error.message : String(error));
3539
+ const errorId = AIError.classifyMessage({
3540
+ api: currentModel.api,
3541
+ errorId: failedMessage.errorId,
3542
+ errorMessage: message,
3543
+ errorStatus: failedMessage.errorStatus,
3544
+ });
3545
+ if (AIError.is(errorId, AIError.Flag.Abort) || AIError.is(errorId, AIError.Flag.UserInterrupt)) return false;
3546
+ if (AIError.isContextOverflow(failedMessage, currentModel.contextWindow ?? 0)) return false;
3547
+
3548
+ const currentSelector = formatRetryFallbackSelector(currentModel, advisor.thinkingLevel);
3549
+
3550
+ const retryAfterMs = extractRetryHint(undefined, message);
3551
+ if (
3552
+ AIError.is(errorId, AIError.Flag.UsageLimit) ||
3553
+ isUsageLimitOutcome(extractHttpStatusFromError(error), message)
3554
+ ) {
3555
+ const outcome = await this.#modelRegistry.authStorage.markUsageLimitReached(
3556
+ currentModel.provider,
3557
+ advisor.providerSessionId,
3558
+ {
3559
+ retryAfterMs,
3560
+ baseUrl: currentModel.baseUrl,
3561
+ modelId: currentModel.id,
3562
+ },
3563
+ );
3564
+ if (outcome.switched) return true;
3565
+ }
3566
+
3567
+ const retrySettings = this.settings.getGroup("retry");
3568
+ if (!retrySettings.enabled || !retrySettings.modelFallback) return false;
3569
+ const role = advisor.retryFallback?.role ?? this.#resolveRetryFallbackRole(currentSelector, currentModel);
3570
+ if (!role || this.#findRetryFallbackCandidates(role, currentSelector, currentModel).length === 0) return false;
3571
+
3572
+ this.#noteRetryFallbackCooldown(currentSelector, retryAfterMs, message);
3573
+ for (const selector of this.#findRetryFallbackCandidates(role, currentSelector, currentModel)) {
3574
+ if (this.#isRetryFallbackSelectorSuppressed(selector)) continue;
3575
+ const resolved = resolveModelOverride([selector.raw], this.#modelRegistry, this.settings);
3576
+ const candidate = resolved.model ?? this.#modelRegistry.find(selector.provider, selector.id);
3577
+ if (!candidate || modelsAreEqual(candidate, currentModel)) continue;
3578
+ const apiKey = await this.#modelRegistry.getApiKey(candidate, advisor.providerSessionId);
3579
+ if (!apiKey) continue;
3580
+
3581
+ const originalThinkingLevel = advisor.thinkingLevel;
3582
+ const requestedThinkingLevel = selector.thinkingLevel ?? originalThinkingLevel;
3583
+ const nextThinkingLevel = this.#setAdvisorModel(advisor, candidate, requestedThinkingLevel);
3584
+ if (advisor.retryFallback) {
3585
+ advisor.retryFallback.lastAppliedThinkingLevel = nextThinkingLevel;
3586
+ } else {
3587
+ advisor.retryFallback = {
3588
+ role,
3589
+ originalSelector: currentSelector,
3590
+ originalThinkingLevel,
3591
+ lastAppliedThinkingLevel: nextThinkingLevel,
3592
+ };
3593
+ }
3594
+ advisor.retryFallbackPendingSuccess = true;
3595
+ this.settings.getStorage()?.recordModelUsage(formatModelStringWithRouting(candidate));
3596
+ await this.#emitSessionEvent({
3597
+ type: "retry_fallback_applied",
3598
+ from: currentSelector,
3599
+ to: selector.raw,
3600
+ role,
3601
+ });
3602
+ return true;
3603
+ }
3604
+ return false;
3605
+ }
3606
+
3254
3607
  async #promoteAdvisorContextModel(advisor: ActiveAdvisor, currentModel: Model): Promise<boolean> {
3255
3608
  const promotionSettings = this.settings.getGroup("contextPromotion");
3256
3609
  if (!promotionSettings.enabled) return false;
@@ -3263,10 +3616,7 @@ export class AgentSession {
3263
3616
  // keeps its suffix across a promotion); only the model changes.
3264
3617
  const advisorThinkingLevel = advisor.thinkingLevel;
3265
3618
  try {
3266
- advisor.agent.setModel(targetModel);
3267
- advisor.agent.setThinkingLevel(toReasoningEffort(advisorThinkingLevel));
3268
- advisor.agent.setDisableReasoning(shouldDisableReasoning(advisorThinkingLevel));
3269
- advisor.agent.appendOnlyContext?.invalidateForModelChange();
3619
+ this.#setAdvisorModel(advisor, targetModel, advisorThinkingLevel);
3270
3620
  logger.debug("Advisor context promotion switched model on overflow", {
3271
3621
  advisor: advisor.name,
3272
3622
  from: `${currentModel.provider}/${currentModel.id}`,
@@ -3285,6 +3635,7 @@ export class AgentSession {
3285
3635
  }
3286
3636
 
3287
3637
  async #maintainAdvisorContext(advisor: ActiveAdvisor, incomingTokens: number): Promise<boolean> {
3638
+ await this.#maybeRestoreAdvisorRetryFallbackPrimary(advisor);
3288
3639
  const agent = advisor.agent;
3289
3640
 
3290
3641
  const compactionSettings = this.settings.getGroup("compaction");
@@ -3296,10 +3647,23 @@ export class AgentSession {
3296
3647
  if (contextWindow <= 0) return false;
3297
3648
 
3298
3649
  const messages = agent.state.messages;
3299
- let contextTokens = incomingTokens;
3650
+ const estimateOptions = { excludeEncryptedReasoning: true } as const;
3651
+ let storedConversationTokens = 0;
3300
3652
  for (const message of messages) {
3301
- contextTokens += estimateTokens(message);
3302
- }
3653
+ storedConversationTokens += estimateTokens(message, estimateOptions);
3654
+ }
3655
+ // Provider usage (including cache reads and generated output) is the
3656
+ // trustworthy anchor for accumulated context. Add only the trailing incoming
3657
+ // delta to that arm. Floor it by a full local estimate — fixed advisor system
3658
+ // prompt, tool schemas, stored messages, and incoming delta — so provider
3659
+ // under-reporting or payload transforms cannot suppress maintenance.
3660
+ const providerContextTokens = this.#estimateAdvisorContextTokens(messages) + incomingTokens;
3661
+ const localContextTokens =
3662
+ countTokens(agent.state.systemPrompt) +
3663
+ estimateToolSchemaTokens(agent.state.tools) +
3664
+ storedConversationTokens +
3665
+ incomingTokens;
3666
+ const contextTokens = compactionContextTokens(providerContextTokens, localContextTokens);
3303
3667
 
3304
3668
  if (!shouldCompact(contextTokens, contextWindow, compactionSettings)) {
3305
3669
  return false;
@@ -3323,6 +3687,7 @@ export class AgentSession {
3323
3687
  const timestamp = String(message.timestamp || Date.now());
3324
3688
 
3325
3689
  if (message.role === "compactionSummary") {
3690
+ const advisorSummary = message as AdvisorCompactionSummaryMessage;
3326
3691
  return {
3327
3692
  type: "compaction",
3328
3693
  id,
@@ -3330,9 +3695,7 @@ export class AgentSession {
3330
3695
  timestamp,
3331
3696
  summary: message.summary,
3332
3697
  shortSummary: message.shortSummary,
3333
- firstKeptEntryId:
3334
- (message as CompactionSummaryMessage & { firstKeptEntryId?: string }).firstKeptEntryId ||
3335
- `msg-${i + 1}`,
3698
+ firstKeptEntryId: advisorSummary.firstKeptEntryId || `msg-${i + 1}`,
3336
3699
  tokensBefore: message.tokensBefore,
3337
3700
  } satisfies CompactionEntry;
3338
3701
  }
@@ -3426,11 +3789,15 @@ export class AgentSession {
3426
3789
  const firstKeptEntryId = compactResult.firstKeptEntryId;
3427
3790
  const tokensBefore = compactResult.tokensBefore;
3428
3791
 
3429
- // Rebuild messages with the compaction summary
3792
+ // The retained messages still carry provider usage from before this
3793
+ // compaction. Record their exact array boundary on the in-memory summary so
3794
+ // only assistants appended afterward can become the next usage anchor.
3795
+ const advisorUsageAnchorStartIndex = preparation.recentMessages.length + 1;
3430
3796
  const summaryMessage = {
3431
3797
  ...createCompactionSummaryMessage(summary, tokensBefore, new Date().toISOString(), shortSummary),
3432
3798
  firstKeptEntryId,
3433
- } as CompactionSummaryMessage & { firstKeptEntryId?: string };
3799
+ advisorUsageAnchorStartIndex,
3800
+ } satisfies AdvisorCompactionSummaryMessage;
3434
3801
 
3435
3802
  agent.replaceMessages([summaryMessage, ...preparation.recentMessages]);
3436
3803
  return false;
@@ -3986,7 +4353,7 @@ export class AgentSession {
3986
4353
  }
3987
4354
  const skipPersistedRewindResult =
3988
4355
  message.role === "toolResult" &&
3989
- message.toolName === "rewind" &&
4356
+ semanticToolResult(message.toolName, message)?.toolName === "rewind" &&
3990
4357
  this.#rewoundToolResultIds.delete(message.toolCallId);
3991
4358
  if (!skipPersistedRewindResult) {
3992
4359
  this.#appendSessionMessage(message);
@@ -4357,29 +4724,28 @@ export class AgentSession {
4357
4724
  }
4358
4725
  }
4359
4726
  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
- };
4727
+ const { toolName, toolCallId, isError, content } = event.message;
4728
+ const details = isRecord(event.message.details) ? event.message.details : undefined;
4729
+ const semanticResult = semanticToolResult(toolName, event.message);
4730
+ const semanticDetails = isRecord(semanticResult?.details) ? semanticResult.details : undefined;
4367
4731
  // A tool actually ran. Clear the post-reminder suppression: the agent did
4368
4732
  // productive work in response to the prior nudge, so the next text-only stop
4369
4733
  // is allowed to escalate to the next reminder if todos remain incomplete.
4370
4734
  this.#todoReminderAwaitingProgress = false;
4371
4735
  // Invalidate streaming edit cache when edit tool completes to prevent stale data
4372
- if (toolName === "edit" && details?.path) {
4373
- this.#invalidateFileCacheForPath(details.path);
4736
+ const editedPath = details ? getStringProperty(details, "path") : undefined;
4737
+ if (toolName === "edit" && editedPath) {
4738
+ this.#invalidateFileCacheForPath(editedPath);
4374
4739
  }
4375
- if (toolName === "todo" && !isError && Array.isArray(details?.phases)) {
4376
- this.setTodoPhases(details.phases);
4740
+ const phases = details?.phases;
4741
+ if (toolName === "todo" && !isError && details && Array.isArray(phases) && phases.every(isTodoPhase)) {
4742
+ this.setTodoPhases(phases);
4377
4743
  if (this.#isTodoInitResult(details, toolCallId)) {
4378
4744
  this.#scheduleReplanTitleRefresh();
4379
4745
  }
4380
4746
  }
4381
4747
  if (toolName === "todo" && isError) {
4382
- const errorText = content?.find(part => part.type === "text")?.text;
4748
+ const errorText = content.find(part => part.type === "text")?.text;
4383
4749
  const reminderText = [
4384
4750
  "<system-reminder>",
4385
4751
  "todo failed, so todo progress is not visible to the user.",
@@ -4397,18 +4763,19 @@ export class AgentSession {
4397
4763
  { deliverAs: "nextTurn" },
4398
4764
  );
4399
4765
  }
4400
- if (toolName === "checkpoint" && !isError) {
4766
+ if (semanticResult?.toolName === "checkpoint" && !isError) {
4401
4767
  const checkpointEntryId = this.sessionManager.getEntries().at(-1)?.id ?? null;
4402
4768
  this.#checkpointState = {
4403
4769
  checkpointMessageCount: this.agent.state.messages.length,
4404
4770
  checkpointEntryId,
4405
- startedAt: details?.startedAt ?? new Date().toISOString(),
4771
+ startedAt:
4772
+ (semanticDetails && stringProperty(semanticDetails, "startedAt")) ?? new Date().toISOString(),
4406
4773
  };
4407
4774
  this.#pendingRewindReport = undefined;
4408
4775
  this.#lastCompletedRewind = undefined;
4409
4776
  }
4410
- if (toolName === "rewind" && !isError && this.#checkpointState) {
4411
- const detailReport = typeof details?.report === "string" ? details.report.trim() : "";
4777
+ if (semanticResult?.toolName === "rewind" && !isError && this.#checkpointState) {
4778
+ const detailReport = semanticDetails ? (stringProperty(semanticDetails, "report")?.trim() ?? "") : "";
4412
4779
  const textReport = content?.find(part => part.type === "text")?.text?.trim() ?? "";
4413
4780
  const report = detailReport || textReport;
4414
4781
  if (report.length > 0) {
@@ -4421,8 +4788,11 @@ export class AgentSession {
4421
4788
  // Check auto-retry and auto-compaction after agent completes
4422
4789
  if (event.type === "agent_end") {
4423
4790
  const settledMessages = this.agent.state.messages;
4424
- const emitAgentEndNotification = async () => {
4425
- await this.#emitAgentEndNotification(settledMessages);
4791
+ // TTSR retry work runs concurrently and clears the live flag before
4792
+ // maintenance can emit agent_end, so preserve the state at settle entry.
4793
+ const ttsrAbortPendingAtAgentEnd = this.#ttsrAbortPending;
4794
+ const emitAgentEndNotification = async (options?: { willContinue?: boolean }) => {
4795
+ await this.#emitAgentEndNotification(settledMessages, options);
4426
4796
  };
4427
4797
  const usage = this.getSessionStats().tokens;
4428
4798
  await this.#goalRuntime.onAgentEnd({
@@ -4524,7 +4894,7 @@ export class AgentSession {
4524
4894
  // active-goal threshold pre-empt below.
4525
4895
  if (await this.#handleEmptyAssistantStop(msg)) {
4526
4896
  maintenanceRoute("empty-stop-handled");
4527
- await emitAgentEndNotification();
4897
+ await emitAgentEndNotification({ willContinue: true });
4528
4898
  return;
4529
4899
  }
4530
4900
 
@@ -4547,30 +4917,34 @@ export class AgentSession {
4547
4917
  automaticContinuationBlocked: compactionResult.automaticContinuationBlocked === true,
4548
4918
  });
4549
4919
  this.#resolveRetry();
4550
- await emitAgentEndNotification();
4920
+ await emitAgentEndNotification(
4921
+ compactionResult.continuationScheduled ? { willContinue: true } : undefined,
4922
+ );
4551
4923
  return;
4552
4924
  }
4553
4925
  }
4554
4926
 
4555
4927
  if (await this.#handleUnexpectedAssistantStop(msg)) {
4556
4928
  maintenanceRoute("unexpected-stop-handled");
4557
- await emitAgentEndNotification();
4929
+ await emitAgentEndNotification({ willContinue: true });
4558
4930
  return;
4559
4931
  }
4560
4932
 
4561
4933
  if (this.#isRetryableReasonlessAbort(msg)) {
4562
4934
  const didRetry = await this.#handleRetryableError(msg, { allowModelFallback: false });
4563
4935
  if (didRetry) {
4564
- await emitAgentEndNotification();
4936
+ await emitAgentEndNotification({ willContinue: true });
4565
4937
  return;
4566
4938
  }
4567
4939
  }
4568
4940
 
4569
- // A deliberate abort should settle the current turn, not trigger queued continuations.
4941
+ // A deliberate abort should settle the current turn, not trigger queued
4942
+ // continuations — except TTSR self-repair, which already scheduled a
4943
+ // hidden retry while #ttsrAbortPending is still true.
4570
4944
  if (msg.stopReason === "aborted") {
4571
4945
  this.#resolveRetry();
4572
4946
  this.#resetSessionStopContinuationState();
4573
- await emitAgentEndNotification();
4947
+ await emitAgentEndNotification(ttsrAbortPendingAtAgentEnd ? { willContinue: true } : undefined);
4574
4948
  return;
4575
4949
  }
4576
4950
  // Fireworks Fast variants degrade to their base model on a failed turn —
@@ -4579,14 +4953,14 @@ export class AgentSession {
4579
4953
  if (this.#isFireworksFastFallbackEligible(msg)) {
4580
4954
  const didRetry = await this.#handleRetryableError(msg, { fireworksFastFallback: true });
4581
4955
  if (didRetry) {
4582
- await emitAgentEndNotification();
4956
+ await emitAgentEndNotification({ willContinue: true });
4583
4957
  return;
4584
4958
  }
4585
4959
  }
4586
4960
  if (this.#isRetryableError(msg)) {
4587
4961
  const didRetry = await this.#handleRetryableError(msg);
4588
4962
  if (didRetry) {
4589
- await emitAgentEndNotification();
4963
+ await emitAgentEndNotification({ willContinue: true });
4590
4964
  return;
4591
4965
  }
4592
4966
  } else if (this.#isHardErrorFallbackEligible(msg)) {
@@ -4597,7 +4971,7 @@ export class AgentSession {
4597
4971
  // backoff-retry of the failing model) when no switch happens.
4598
4972
  const didRetry = await this.#handleRetryableError(msg, { hardErrorFallback: true });
4599
4973
  if (didRetry) {
4600
- await emitAgentEndNotification();
4974
+ await emitAgentEndNotification({ willContinue: true });
4601
4975
  return;
4602
4976
  }
4603
4977
  }
@@ -4636,22 +5010,22 @@ export class AgentSession {
4636
5010
  compactionResult.continuationScheduled ||
4637
5011
  compactionResult.automaticContinuationBlocked
4638
5012
  ) {
4639
- await emitAgentEndNotification();
5013
+ await emitAgentEndNotification(compactionResult.continuationScheduled ? { willContinue: true } : undefined);
4640
5014
  return;
4641
5015
  }
4642
5016
  if (msg.stopReason !== "error") {
4643
5017
  if (this.#enforceRewindBeforeYield()) {
4644
- await emitAgentEndNotification();
5018
+ await emitAgentEndNotification({ willContinue: true });
4645
5019
  return;
4646
5020
  }
4647
5021
  const planModeContinuationScheduled = await this.#enforcePlanModeDecisionAtSettle();
4648
5022
  if (planModeContinuationScheduled) {
4649
- await emitAgentEndNotification();
5023
+ await emitAgentEndNotification({ willContinue: true });
4650
5024
  return;
4651
5025
  }
4652
5026
  const todoContinuationScheduled = await this.#checkTodoCompletion(msg);
4653
5027
  if (todoContinuationScheduled) {
4654
- await emitAgentEndNotification();
5028
+ await emitAgentEndNotification({ willContinue: true });
4655
5029
  return;
4656
5030
  }
4657
5031
  }
@@ -4661,11 +5035,11 @@ export class AgentSession {
4661
5035
  // the session is fully idle (the todo reminder above defers the same
4662
5036
  // way inside #checkTodoCompletion).
4663
5037
  if (this.#hasPendingAsyncWake()) {
4664
- await emitAgentEndNotification();
5038
+ await emitAgentEndNotification({ willContinue: true });
4665
5039
  return;
4666
5040
  }
4667
- await this.#emitSessionStopEvent(settledMessages, msg);
4668
- await emitAgentEndNotification();
5041
+ const sessionStopWillContinue = await this.#emitSessionStopEvent(settledMessages, msg);
5042
+ await emitAgentEndNotification(sessionStopWillContinue ? { willContinue: true } : undefined);
4669
5043
  }
4670
5044
  };
4671
5045
 
@@ -4795,7 +5169,28 @@ export class AgentSession {
4795
5169
  );
4796
5170
  }
4797
5171
 
4798
- #scheduleAutoContinuePrompt(generation: number): void {
5172
+ #scheduleCompactionContinuation(options: {
5173
+ generation: number;
5174
+ autoContinue: boolean;
5175
+ terminalTextAnswer: boolean;
5176
+ suppressContinuation: boolean;
5177
+ }): boolean {
5178
+ if (options.suppressContinuation) return false;
5179
+ if (this.agent.hasQueuedMessages()) {
5180
+ this.#scheduleAgentContinue({
5181
+ delayMs: 100,
5182
+ generation: options.generation,
5183
+ shouldContinue: () => this.agent.hasQueuedMessages(),
5184
+ });
5185
+ return true;
5186
+ }
5187
+ if (!options.autoContinue) return false;
5188
+ const activeGoal = this.#goalModeState?.enabled === true && this.#goalModeState.goal.status === "active";
5189
+ if (options.terminalTextAnswer && !activeGoal) return false;
5190
+ return this.#scheduleAutoContinuePrompt(options.generation);
5191
+ }
5192
+
5193
+ #scheduleAutoContinuePrompt(generation: number): boolean {
4799
5194
  const continuePrompt = async () => {
4800
5195
  // Compaction summarizes away the first-message eager preludes, so re-assert the
4801
5196
  // delegate-via-tasks / phased-todo reminders on this auto-resumed turn. This runs
@@ -4820,10 +5215,18 @@ export class AgentSession {
4820
5215
  async signal => {
4821
5216
  await Promise.resolve();
4822
5217
  if (signal.aborted) return;
5218
+ if (this.agent.hasQueuedMessages()) {
5219
+ this.#scheduleAgentContinue({
5220
+ generation,
5221
+ shouldContinue: () => this.agent.hasQueuedMessages(),
5222
+ });
5223
+ return;
5224
+ }
4823
5225
  await continuePrompt();
4824
5226
  },
4825
5227
  { generation },
4826
5228
  );
5229
+ return true;
4827
5230
  }
4828
5231
 
4829
5232
  async #cancelPostPromptTasks(): Promise<void> {
@@ -5884,15 +6287,22 @@ export class AgentSession {
5884
6287
  return undefined;
5885
6288
  }
5886
6289
 
5887
- async #emitAgentEndNotification(messages: AgentMessage[]): Promise<void> {
5888
- await this.#extensionRunner?.emit({ type: "agent_end", messages });
6290
+ async #emitAgentEndNotification(messages: AgentMessage[], options?: { willContinue?: boolean }): Promise<void> {
6291
+ await this.#extensionRunner?.emit({
6292
+ type: "agent_end",
6293
+ messages,
6294
+ willContinue: options?.willContinue,
6295
+ });
5889
6296
  }
5890
6297
 
6298
+ /** @returns true when a hidden session_stop continuation turn was scheduled. */
5891
6299
  async #emitSessionStopEvent(
5892
6300
  messages: AgentMessage[],
5893
6301
  lastAssistantMessage = this.getLastAssistantMessage(),
5894
- ): Promise<void> {
5895
- if (this.#agentKind === "sub" || !this.#extensionRunner?.hasHandlers("session_stop")) return;
6302
+ ): Promise<boolean> {
6303
+ if (this.#agentKind === "sub" || !this.#extensionRunner?.hasHandlers("session_stop")) {
6304
+ return false;
6305
+ }
5896
6306
  const generation = this.#promptGeneration;
5897
6307
  const result = await this.#extensionRunner.emitSessionStop({
5898
6308
  messages,
@@ -5904,12 +6314,12 @@ export class AgentSession {
5904
6314
  });
5905
6315
  if (this.#promptGeneration !== generation || this.#abortInProgress || this.#isDisposed) {
5906
6316
  this.#resetSessionStopContinuationState();
5907
- return;
6317
+ return false;
5908
6318
  }
5909
6319
  const additionalContext = this.#sessionStopContinuationContext(result);
5910
6320
  if (!additionalContext) {
5911
6321
  this.#resetSessionStopContinuationState();
5912
- return;
6322
+ return false;
5913
6323
  }
5914
6324
  if (this.#sessionStopContinuationCount >= SESSION_STOP_CONTINUATION_CAP) {
5915
6325
  logger.warn("session_stop continuation cap reached", {
@@ -5917,7 +6327,7 @@ export class AgentSession {
5917
6327
  cap: SESSION_STOP_CONTINUATION_CAP,
5918
6328
  });
5919
6329
  this.#resetSessionStopContinuationState();
5920
- return;
6330
+ return false;
5921
6331
  }
5922
6332
  this.#sessionStopContinuationCount++;
5923
6333
  this.#sessionStopHookActive = true;
@@ -5932,6 +6342,7 @@ export class AgentSession {
5932
6342
  },
5933
6343
  true,
5934
6344
  );
6345
+ return true;
5935
6346
  }
5936
6347
 
5937
6348
  /** Emit extension events based on session events */
@@ -6204,6 +6615,43 @@ export class AgentSession {
6204
6615
  await this.refreshBaseSystemPrompt();
6205
6616
  }
6206
6617
  }
6618
+ /** Run one abortable auto-learn capture outside the primary agent loop. */
6619
+ async runAutolearnCapture(capture: (signal: AbortSignal) => Promise<void>): Promise<void> {
6620
+ if (this.#autolearnCaptureTask || this.#isDisposed) return;
6621
+ const controller = new AbortController();
6622
+ this.#autolearnCaptureAbortController = controller;
6623
+ const task = (async () => {
6624
+ try {
6625
+ await capture(controller.signal);
6626
+ } catch (error) {
6627
+ if (!controller.signal.aborted) throw error;
6628
+ } finally {
6629
+ if (this.#autolearnCaptureAbortController === controller) {
6630
+ this.#autolearnCaptureAbortController = undefined;
6631
+ }
6632
+ }
6633
+ })();
6634
+ this.#autolearnCaptureTask = task;
6635
+ try {
6636
+ await task;
6637
+ } finally {
6638
+ if (this.#autolearnCaptureTask === task) this.#autolearnCaptureTask = undefined;
6639
+ }
6640
+ }
6641
+
6642
+ #abortAutolearnCapture(): void {
6643
+ this.#autolearnCaptureAbortController?.abort();
6644
+ }
6645
+
6646
+ async #drainAutolearnCapture(): Promise<void> {
6647
+ const task = this.#autolearnCaptureTask;
6648
+ if (!task) return;
6649
+ try {
6650
+ await withTimeout(task, 3_000, "Timed out draining auto-learn capture during dispose");
6651
+ } catch (error) {
6652
+ logger.warn("Auto-learn capture did not settle during dispose", { error: String(error) });
6653
+ }
6654
+ }
6207
6655
 
6208
6656
  /** True once dispose() has begun; deferred background work (e.g. the deferred
6209
6657
  * MCP discovery task in sdk.ts) must not touch the session past this point. */
@@ -6226,6 +6674,8 @@ export class AgentSession {
6226
6674
  */
6227
6675
  beginDispose(): void {
6228
6676
  this.#isDisposed = true;
6677
+ this.#titleGenerationAbortController.abort();
6678
+ this.#abortAutolearnCapture();
6229
6679
  this.#flushPendingIrcAsides();
6230
6680
  this.yieldQueue.clear();
6231
6681
  this.agent.setAsideMessageProvider(undefined);
@@ -6262,6 +6712,12 @@ export class AgentSession {
6262
6712
  } catch (error) {
6263
6713
  logger.warn("Failed to emit session_shutdown event", { error: String(error) });
6264
6714
  }
6715
+ // Clear any timers extensions scheduled via `ctx.setInterval`/`ctx.setTimeout`
6716
+ // so their background work does not outlive the session (issue #5664).
6717
+ // Optional-called: hosts and tests may inject partial runner facades that
6718
+ // implement only the dispatch surface.
6719
+ this.#extensionRunner?.clearManagedTimers?.();
6720
+ this.#fallbackExtensionTimers?.clearAll();
6265
6721
  // Abort post-prompt work so the drain below can complete. Without this, a
6266
6722
  // deferred-handoff task that has already advanced into
6267
6723
  // `await this.handoff(...) → generateHandoff(...)` keeps awaiting a live LLM stream
@@ -6279,6 +6735,7 @@ export class AgentSession {
6279
6735
  const postPromptDrain = this.#cancelPostPromptTasks();
6280
6736
  this.agent.abort();
6281
6737
  await postPromptDrain;
6738
+ await this.#drainAutolearnCapture();
6282
6739
  // Cancel jobs this agent registered so a subagent's teardown doesn't
6283
6740
  // leak its background bash/task work into the parent's manager. Only
6284
6741
  // the session that owns the manager goes on to dispose it (which itself
@@ -6753,50 +7210,99 @@ export class AgentSession {
6753
7210
 
6754
7211
  async #applyActiveToolsByName(toolNames: string[]): Promise<void> {
6755
7212
  toolNames = normalizeToolNames(toolNames);
7213
+ const selectedTools = toolNames.flatMap(name => {
7214
+ const tool = this.#toolRegistry.get(name);
7215
+ return tool ? [{ name, tool }] : [];
7216
+ });
7217
+ const xdevReadAvailable = this.#builtInToolNames.has("read") && selectedTools.some(({ name }) => name === "read");
7218
+ const isPresentationPinned = (name: string): boolean =>
7219
+ this.#presentationPinnedToolNames?.has(name) === true || this.#runtimeSelectedToolNames?.has(name) === true;
7220
+ const mountCandidates = selectedTools.filter(
7221
+ ({ name, tool }) =>
7222
+ this.#xdevRegistry !== undefined &&
7223
+ xdevReadAvailable &&
7224
+ !isPresentationPinned(name) &&
7225
+ isMountableUnderXdev(tool),
7226
+ );
7227
+
7228
+ let builtInWriteAvailable = this.#builtInToolNames.has("write");
7229
+ if (mountCandidates.length > 0 && !builtInWriteAvailable) {
7230
+ builtInWriteAvailable = (await this.#ensureWriteRegistered?.()) === true;
7231
+ if (builtInWriteAvailable) this.#builtInToolNames.add("write");
7232
+ }
7233
+ const mountNames = builtInWriteAvailable ? new Set(mountCandidates.map(({ name }) => name)) : new Set<string>();
6756
7234
  const tools: AgentTool[] = [];
6757
7235
  const validToolNames: string[] = [];
6758
7236
  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);
7237
+ for (const { name, tool } of selectedTools) {
7238
+ if (mountNames.has(name)) {
7239
+ mountedTools.push(this.#wrapToolForAcpPermission(tool));
6767
7240
  } else {
6768
7241
  tools.push(this.#wrapToolForAcpPermission(tool));
6769
7242
  validToolNames.push(name);
6770
7243
  }
6771
7244
  }
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.
7245
+
7246
+ const pinnedWrite = isPresentationPinned("write");
7247
+ const activeDeferrableTool = tools.some(tool => tool.deferrable === true);
7248
+ const transportNeeded = mountedTools.length > 0 || activeDeferrableTool || this.#planModeState?.enabled === true;
7249
+ if (transportNeeded && !builtInWriteAvailable) {
7250
+ builtInWriteAvailable = (await this.#ensureWriteRegistered?.()) === true;
7251
+ if (builtInWriteAvailable) this.#builtInToolNames.add("write");
7252
+ }
7253
+ if (transportNeeded && builtInWriteAvailable) {
7254
+ const write = this.#toolRegistry.get("write");
7255
+ if (write && !validToolNames.includes("write")) {
7256
+ tools.push(this.#wrapToolForAcpPermission(write));
7257
+ validToolNames.push("write");
7258
+ }
7259
+ } else if (
7260
+ !pinnedWrite &&
7261
+ (this.#presentationPinnedToolNames !== undefined || this.#runtimeSelectedToolNames !== undefined)
7262
+ ) {
7263
+ const writeNameIndex = validToolNames.indexOf("write");
7264
+ if (writeNameIndex >= 0 && this.#builtInToolNames.has("write")) validToolNames.splice(writeNameIndex, 1);
7265
+ const writeToolIndex = tools.findIndex(tool => tool.name === "write" && this.#builtInToolNames.has("write"));
7266
+ if (writeToolIndex >= 0) tools.splice(writeToolIndex, 1);
7267
+ }
7268
+
6775
7269
  const previousMounted = this.#mountedXdevToolNames;
7270
+ const previousMountedTools = [...previousMounted].flatMap(name => {
7271
+ const tool = this.#xdevRegistry?.get(name);
7272
+ return tool ? [tool] : [];
7273
+ });
7274
+ const previousActiveToolNames = this.getActiveToolNames();
6776
7275
  this.#mountedXdevToolNames = new Set(mountedTools.map(tool => tool.name));
6777
7276
  this.#xdevRegistry?.reconcile(mountedTools);
6778
- this.#notifyXdevMountDelta(previousMounted);
6779
7277
  this.#setActiveToolNames?.(validToolNames);
6780
- this.agent.setTools(tools);
6781
7278
 
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();
7279
+ let rebuiltSystemPrompt: string[] | undefined;
7280
+ let rebuiltSignature: string | undefined;
7281
+ try {
7282
+ if (this.#rebuildSystemPrompt) {
7283
+ const signature = this.#computeAppliedToolSignature(validToolNames, tools);
7284
+ if (signature !== this.#lastAppliedToolSignature) {
7285
+ const built = await this.#rebuildSystemPrompt(validToolNames, this.#toolRegistry);
7286
+ rebuiltSystemPrompt = built.systemPrompt;
7287
+ rebuiltSignature = signature;
6792
7288
  }
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
7289
  }
7290
+ } catch (error) {
7291
+ this.#mountedXdevToolNames = previousMounted;
7292
+ this.#xdevRegistry?.reconcile(previousMountedTools);
7293
+ this.#setActiveToolNames?.(previousActiveToolNames);
7294
+ throw error;
7295
+ }
7296
+
7297
+ this.#notifyXdevMountDelta(previousMounted);
7298
+ this.agent.setTools(tools);
7299
+ if (rebuiltSystemPrompt && rebuiltSignature) {
7300
+ if (this.#lastAppliedToolSignature !== undefined) this.#clearInheritedProviderPromptCacheKey();
7301
+ this.#baseSystemPrompt = rebuiltSystemPrompt;
7302
+ this.#baseSystemPromptBeforeMemoryPromotion = undefined;
7303
+ this.agent.setSystemPrompt(this.#baseSystemPrompt);
7304
+ this.#lastAppliedToolSignature = rebuiltSignature;
7305
+ this.#promptModelKey = this.#currentPromptModelKey();
6800
7306
  }
6801
7307
  }
6802
7308
 
@@ -6825,6 +7331,7 @@ export class AgentSession {
6825
7331
  display: false,
6826
7332
  timestamp: Date.now(),
6827
7333
  });
7334
+ if (this.settings.get("startup.quiet")) return;
6828
7335
  const parts: string[] = [];
6829
7336
  if (added.length > 0) parts.push(`mounted ${added.map(entry => entry.name).join(", ")}`);
6830
7337
  if (removed.length > 0) parts.push(`unmounted ${removed.map(entry => entry.name).join(", ")}`);
@@ -6866,7 +7373,24 @@ export class AgentSession {
6866
7373
  * Changes take effect before the next model call.
6867
7374
  */
6868
7375
  async setActiveToolsByName(toolNames: string[]): Promise<void> {
6869
- await this.#applyActiveToolsByName(toolNames);
7376
+ const mounted = this.#mountedXdevToolNames;
7377
+ const normalized = normalizeToolNames(toolNames);
7378
+ const transportWriteActive =
7379
+ this.#builtInToolNames.has("write") &&
7380
+ this.getActiveToolNames().includes("write") &&
7381
+ this.#presentationPinnedToolNames?.has("write") !== true &&
7382
+ this.#runtimeSelectedToolNames?.has("write") !== true &&
7383
+ (mounted.size > 0 || this.#planModeState?.enabled === true);
7384
+ const previousRuntimeSelectedToolNames = this.#runtimeSelectedToolNames;
7385
+ this.#runtimeSelectedToolNames = new Set(
7386
+ normalized.filter(name => !mounted.has(name) && !(name === "write" && transportWriteActive)),
7387
+ );
7388
+ try {
7389
+ await this.#applyActiveToolsByName(normalized);
7390
+ } catch (error) {
7391
+ this.#runtimeSelectedToolNames = previousRuntimeSelectedToolNames;
7392
+ throw error;
7393
+ }
6870
7394
  }
6871
7395
 
6872
7396
  /** Rebuild the base system prompt using the current active tool set. */
@@ -7009,6 +7533,12 @@ export class AgentSession {
7009
7533
  */
7010
7534
  async refreshMCPTools(mcpTools: CustomTool[]): Promise<void> {
7011
7535
  const existingNames = Array.from(this.#toolRegistry.keys());
7536
+ const previousMcpTools = new Map(
7537
+ existingNames.flatMap(name => {
7538
+ const tool = this.#toolRegistry.get(name);
7539
+ return isMCPToolName(name) && tool ? [[name, tool] as const] : [];
7540
+ }),
7541
+ );
7012
7542
  for (const name of existingNames) {
7013
7543
  if (isMCPToolName(name)) {
7014
7544
  this.#toolRegistry.delete(name);
@@ -7036,10 +7566,18 @@ export class AgentSession {
7036
7566
  this.#toolRegistry.set(finalTool.name, finalTool);
7037
7567
  }
7038
7568
 
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.
7569
+ // Every connected MCP tool is selected; centralized repartitioning owns
7570
+ // presentation pins and write-transport activation/removal.
7041
7571
  const nextActive = [...new Set([...this.#getActiveNonMCPToolNames(), ...mcpTools.map(tool => tool.name)])];
7042
- await this.#applyActiveToolsByName(nextActive);
7572
+ try {
7573
+ await this.#applyActiveToolsByName(nextActive);
7574
+ } catch (error) {
7575
+ for (const name of this.#toolRegistry.keys()) {
7576
+ if (isMCPToolName(name)) this.#toolRegistry.delete(name);
7577
+ }
7578
+ for (const [name, tool] of previousMcpTools) this.#toolRegistry.set(name, tool);
7579
+ throw error;
7580
+ }
7043
7581
  }
7044
7582
 
7045
7583
  /**
@@ -7060,6 +7598,12 @@ export class AgentSession {
7060
7598
 
7061
7599
  const previousRpcHostToolNames = new Set(this.#rpcHostToolNames);
7062
7600
  const previousActiveToolNames = this.getEnabledToolNames();
7601
+ const previousRpcHostTools = new Map(
7602
+ [...previousRpcHostToolNames].flatMap(name => {
7603
+ const tool = this.#toolRegistry.get(name);
7604
+ return tool ? [[name, tool] as const] : [];
7605
+ }),
7606
+ );
7063
7607
  for (const name of previousRpcHostToolNames) {
7064
7608
  this.#toolRegistry.delete(name);
7065
7609
  }
@@ -7081,9 +7625,16 @@ export class AgentSession {
7081
7625
  const autoActivatedRpcToolNames = rpcTools
7082
7626
  .filter(tool => !tool.hidden && !previousRpcHostToolNames.has(tool.name))
7083
7627
  .map(tool => tool.name);
7084
- await this.#applyActiveToolsByName(
7085
- Array.from(new Set([...activeNonRpcToolNames, ...preservedRpcToolNames, ...autoActivatedRpcToolNames])),
7086
- );
7628
+ try {
7629
+ await this.#applyActiveToolsByName(
7630
+ Array.from(new Set([...activeNonRpcToolNames, ...preservedRpcToolNames, ...autoActivatedRpcToolNames])),
7631
+ );
7632
+ } catch (error) {
7633
+ for (const name of this.#rpcHostToolNames) this.#toolRegistry.delete(name);
7634
+ this.#rpcHostToolNames = previousRpcHostToolNames;
7635
+ for (const [name, tool] of previousRpcHostTools) this.#toolRegistry.set(name, tool);
7636
+ throw error;
7637
+ }
7087
7638
  }
7088
7639
 
7089
7640
  /** Whether auto-compaction is currently running */
@@ -7411,6 +7962,11 @@ export class AgentSession {
7411
7962
  .filter((tool): tool is AgentTool => tool !== undefined)
7412
7963
  .map(tool => this.#wrapToolForAcpPermission(tool));
7413
7964
  this.agent.setTools(activeTools);
7965
+ const mountedTools = [...this.#mountedXdevToolNames]
7966
+ .map(name => this.#toolRegistry.get(name))
7967
+ .filter((tool): tool is AgentTool => tool !== undefined)
7968
+ .map(tool => this.#wrapToolForAcpPermission(tool));
7969
+ this.#xdevRegistry?.reconcile(mountedTools);
7414
7970
  }
7415
7971
 
7416
7972
  #clearCheckpointRuntimeState(): void {
@@ -8072,7 +8628,7 @@ export class AgentSession {
8072
8628
  const generation = this.#promptGeneration;
8073
8629
  try {
8074
8630
  // Flush any pending bash messages before the new prompt
8075
- this.#flushPendingBashMessages();
8631
+ await this.#flushPendingBashMessages();
8076
8632
  this.#flushPendingPythonMessages();
8077
8633
  this.#flushPendingIrcAsides();
8078
8634
 
@@ -8346,9 +8902,20 @@ export class AgentSession {
8346
8902
  await this.reload();
8347
8903
  },
8348
8904
  getSystemPrompt: () => this.systemPrompt,
8905
+ setInterval: (callback, ms, ...args) => this.#fallbackTimers().setInterval(callback, ms, ...args),
8906
+ setTimeout: (callback, ms, ...args) => this.#fallbackTimers().setTimeout(callback, ms, ...args),
8907
+ clearTimer: timer => this.#fallbackTimers().clear(timer),
8349
8908
  };
8350
8909
  }
8351
8910
 
8911
+ /** Lazily create the runner-less command-context timer registry (#5664). */
8912
+ #fallbackTimers(): ManagedTimers {
8913
+ this.#fallbackExtensionTimers ??= new ManagedTimers((event, error) =>
8914
+ logger.warn("Extension timer callback threw", { event, error }),
8915
+ );
8916
+ return this.#fallbackExtensionTimers;
8917
+ }
8918
+
8352
8919
  /**
8353
8920
  * Try to execute a custom command. Returns the prompt string if found, null otherwise.
8354
8921
  * If the command returns void, returns empty string to indicate it was handled.
@@ -8982,16 +9549,26 @@ export class AgentSession {
8982
9549
  this.#replanTitleRefreshInFlight = refresh;
8983
9550
  }
8984
9551
 
8985
- async #refreshTitleAfterReplan(context: string, sessionId: string): Promise<void> {
8986
- const title = await generateSessionTitle(
8987
- context,
9552
+ /**
9553
+ * Generate an automatic session title tied to this session's lifecycle.
9554
+ * Input and replan callers share the signal so disposal cancels provider and
9555
+ * local-worker requests instead of leaving background inference alive.
9556
+ */
9557
+ generateTitle(firstMessage: string): Promise<string | null> {
9558
+ return generateSessionTitle(
9559
+ firstMessage,
8988
9560
  this.#modelRegistry,
8989
9561
  this.settings,
8990
- sessionId,
9562
+ this.sessionId,
8991
9563
  this.model,
8992
9564
  provider => this.agent.metadataForProvider(provider),
8993
9565
  this.#titleSystemPrompt,
9566
+ this.#titleGenerationAbortController.signal,
8994
9567
  );
9568
+ }
9569
+
9570
+ async #refreshTitleAfterReplan(context: string, sessionId: string): Promise<void> {
9571
+ const title = await this.generateTitle(context);
8995
9572
  if (!title) return;
8996
9573
  if (this.sessionManager.getSessionId() !== sessionId) return;
8997
9574
  if (!this.settings.get("title.refreshOnReplan")) return;
@@ -9059,6 +9636,7 @@ export class AgentSession {
9059
9636
  // auto-starting a fresh turn during cleanup.
9060
9637
  this.#abortInProgress = true;
9061
9638
  try {
9639
+ this.#abortAutolearnCapture();
9062
9640
  this.abortRetry();
9063
9641
  this.#promptGeneration++;
9064
9642
  this.#scheduledHiddenNextTurnGeneration = undefined;
@@ -9080,6 +9658,7 @@ export class AgentSession {
9080
9658
  this.agent.abort(options?.reason);
9081
9659
  await postPromptDrain;
9082
9660
  await this.agent.waitForIdle();
9661
+ await this.#drainAutolearnCapture();
9083
9662
  await this.#goalRuntime.onTaskAborted({ reason: options?.goalReason ?? "interrupted" });
9084
9663
  // Clear prompt-in-flight state: waitForIdle resolves when the agent loop's finally
9085
9664
  // block runs, but nested prompt setup/finalizers may still be unwinding. Without this,
@@ -9139,27 +9718,36 @@ export class AgentSession {
9139
9718
  await this.abort();
9140
9719
  this.#cancelOwnAsyncJobs();
9141
9720
  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 });
9721
+ await this.#flushPendingBashMessages();
9722
+ const bashTransition = this.#beginBashSessionTransition({ persistDetached: options?.drop !== true });
9723
+ let sessionTransitioned = false;
9724
+ try {
9725
+ this.agent.reset();
9726
+ if (options?.drop && previousSessionFile) {
9727
+ // Detach the advisor recorder feed and drain its writer BEFORE deleting the
9728
+ // old artifacts dir: `await this.abort()` only stops the primary, so a still-
9729
+ // running advisor turn could otherwise finish, emit `message_end`, and recreate
9730
+ // `<old>/__advisor.jsonl`. #resetAdvisorSessionState (after newSession) re-primes
9731
+ // the advisor and re-attaches the feed at the new session's path.
9732
+ for (const a of this.#advisors) {
9733
+ a.agentUnsubscribe?.();
9734
+ a.agentUnsubscribe = undefined;
9735
+ await a.recorder.close();
9736
+ }
9737
+ try {
9738
+ await this.sessionManager.dropSession(previousSessionFile);
9739
+ } catch (err) {
9740
+ logger.error("Failed to delete session during /drop", { err });
9741
+ }
9742
+ } else {
9743
+ await this.sessionManager.flush();
9158
9744
  }
9159
- } else {
9160
- await this.sessionManager.flush();
9745
+ await this.sessionManager.newSession(options);
9746
+ this.#markBashSessionTransition(bashTransition);
9747
+ sessionTransitioned = true;
9748
+ } finally {
9749
+ this.#finishBashSessionTransition(bashTransition, sessionTransitioned);
9161
9750
  }
9162
- await this.sessionManager.newSession(options);
9163
9751
 
9164
9752
  this.#clearCheckpointRuntimeState();
9165
9753
  this.setTodoPhases([]);
@@ -9225,14 +9813,25 @@ export class AgentSession {
9225
9813
  }
9226
9814
  }
9227
9815
 
9816
+ await this.#flushPendingBashMessages();
9228
9817
  // Flush current session to ensure all entries are written
9229
9818
  await this.sessionManager.flush();
9819
+ const bashTransition = this.#beginBashSessionTransition();
9230
9820
 
9231
9821
  // Fork the session (creates new session file with same entries)
9232
- const forkResult = await this.sessionManager.fork();
9822
+ let forkResult: { oldSessionFile: string; newSessionFile: string } | undefined;
9823
+ try {
9824
+ forkResult = await this.sessionManager.fork();
9825
+ } catch (error) {
9826
+ this.#finishBashSessionTransition(bashTransition, false);
9827
+ throw error;
9828
+ }
9233
9829
  if (!forkResult) {
9830
+ this.#finishBashSessionTransition(bashTransition, false);
9234
9831
  return false;
9235
9832
  }
9833
+ this.#markBashSessionTransition(bashTransition);
9834
+ this.#finishBashSessionTransition(bashTransition, true);
9236
9835
 
9237
9836
  // Copy artifacts directory if it exists
9238
9837
  const oldArtifactDir = forkResult.oldSessionFile.slice(0, -6);
@@ -10608,9 +11207,20 @@ export class AgentSession {
10608
11207
  return undefined;
10609
11208
  }
10610
11209
  }
11210
+ await this.#flushPendingBashMessages();
10611
11211
  await this.sessionManager.flush();
11212
+ const bashTransition = this.#beginBashSessionTransition();
10612
11213
  this.#cancelOwnAsyncJobs();
10613
- await this.sessionManager.newSession(previousSessionFile ? { parentSession: previousSessionFile } : undefined);
11214
+ let sessionTransitioned = false;
11215
+ try {
11216
+ await this.sessionManager.newSession(
11217
+ previousSessionFile ? { parentSession: previousSessionFile } : undefined,
11218
+ );
11219
+ this.#markBashSessionTransition(bashTransition);
11220
+ sessionTransitioned = true;
11221
+ } finally {
11222
+ this.#finishBashSessionTransition(bashTransition, sessionTransitioned);
11223
+ }
10614
11224
 
10615
11225
  this.#clearCheckpointRuntimeState();
10616
11226
  // agent.reset() clears the core steering/follow-up queues. Preserve any queued
@@ -11070,6 +11680,7 @@ export class AgentSession {
11070
11680
  autoContinue,
11071
11681
  triggerContextTokens: postMaintenanceContextTokens,
11072
11682
  phase: "pre_turn",
11683
+ terminalTextAnswer: isTerminalTextAssistantAnswer(assistantMessage),
11073
11684
  });
11074
11685
  }
11075
11686
  logger.debug("Auto-compaction threshold satisfied but context promotion took over", {
@@ -11503,11 +12114,13 @@ export class AgentSession {
11503
12114
 
11504
12115
  if (!branchEntry) return;
11505
12116
  const targetParentId = prunePrompt ? parentEntry.parentId : branchEntry.parentId;
11506
- if (targetParentId === null) {
11507
- this.sessionManager.resetLeaf();
11508
- } else {
11509
- this.sessionManager.branch(targetParentId);
11510
- }
12117
+ this.#withBashBranchTransition(() => {
12118
+ if (targetParentId === null) {
12119
+ this.sessionManager.resetLeaf();
12120
+ } else {
12121
+ this.sessionManager.branch(targetParentId);
12122
+ }
12123
+ });
11511
12124
  this.sessionManager.appendCustomEntry("accepted-terminal-empty-stop");
11512
12125
  }
11513
12126
 
@@ -11534,11 +12147,13 @@ export class AgentSession {
11534
12147
  if (!branchEntry) {
11535
12148
  return;
11536
12149
  }
11537
- if (branchEntry.parentId === null) {
11538
- this.sessionManager.resetLeaf();
11539
- } else {
11540
- this.sessionManager.branch(branchEntry.parentId);
11541
- }
12150
+ this.#withBashBranchTransition(() => {
12151
+ if (branchEntry.parentId === null) {
12152
+ this.sessionManager.resetLeaf();
12153
+ } else {
12154
+ this.sessionManager.branch(branchEntry.parentId);
12155
+ }
12156
+ });
11542
12157
  }
11543
12158
 
11544
12159
  #isSameAssistantMessage(left: AssistantMessage, right: AssistantMessage): boolean {
@@ -11575,8 +12190,10 @@ export class AgentSession {
11575
12190
  if (this.#pendingRewindReport) return this.#pendingRewindReport;
11576
12191
  for (let i = messages.length - 1; i >= 0; i--) {
11577
12192
  const message = messages[i];
11578
- if (message?.role !== "toolResult" || message.toolName !== "rewind" || message.isError) continue;
11579
- const details = message.details;
12193
+ if (message?.role !== "toolResult" || message.isError) continue;
12194
+ const semanticResult = semanticToolResult(message.toolName, message);
12195
+ if (semanticResult?.toolName !== "rewind") continue;
12196
+ const details = semanticResult.details;
11580
12197
  const detailReport =
11581
12198
  details && typeof details === "object" && "report" in details && typeof details.report === "string"
11582
12199
  ? details.report.trim()
@@ -11593,16 +12210,18 @@ export class AgentSession {
11593
12210
  if (!checkpointState) {
11594
12211
  return;
11595
12212
  }
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
- }
12213
+ this.#withBashBranchTransition(() => {
12214
+ try {
12215
+ this.sessionManager.branchWithSummary(checkpointState.checkpointEntryId, report, {
12216
+ startedAt: checkpointState.startedAt,
12217
+ });
12218
+ } catch (error) {
12219
+ logger.warn("Rewind branch checkpoint missing, falling back to root", {
12220
+ error: error instanceof Error ? error.message : String(error),
12221
+ });
12222
+ this.sessionManager.branchWithSummary(null, report, { startedAt: checkpointState.startedAt });
12223
+ }
12224
+ });
11606
12225
 
11607
12226
  const rewoundAt = new Date().toISOString();
11608
12227
  const details = { report, startedAt: checkpointState.startedAt, rewoundAt };
@@ -11617,7 +12236,7 @@ export class AgentSession {
11617
12236
 
11618
12237
  if (activeMessages) {
11619
12238
  for (const message of activeMessages) {
11620
- if (message.role === "toolResult" && message.toolName === "rewind") {
12239
+ if (message.role === "toolResult" && semanticToolResult(message.toolName, message)?.toolName === "rewind") {
11621
12240
  this.#rewoundToolResultIds.add(message.toolCallId);
11622
12241
  }
11623
12242
  }
@@ -12916,12 +13535,15 @@ export class AgentSession {
12916
13535
  suppressContinuation?: boolean;
12917
13536
  suppressHandoff?: boolean;
12918
13537
  phase?: CodexCompactionContext["phase"];
13538
+ terminalTextAnswer?: boolean;
12919
13539
  } = {},
12920
13540
  ): Promise<CompactionCheckResult> {
12921
13541
  const compactionSettings = this.settings.getGroup("compaction");
12922
13542
  if (compactionSettings.strategy === "off") return COMPACTION_CHECK_NONE;
12923
13543
  if (reason !== "idle" && !compactionSettings.enabled) return COMPACTION_CHECK_NONE;
12924
13544
  const generation = this.#promptGeneration;
13545
+ const terminalTextAnswer =
13546
+ options.terminalTextAnswer ?? isTerminalTextAssistantAnswer(this.#findLastAssistantMessage());
12925
13547
  const suppressContinuation = options.suppressContinuation === true;
12926
13548
  const shouldAutoContinue =
12927
13549
  !suppressContinuation && options.autoContinue !== false && compactionSettings.autoContinue !== false;
@@ -12936,6 +13558,7 @@ export class AgentSession {
12936
13558
  willRetry,
12937
13559
  generation,
12938
13560
  shouldAutoContinue,
13561
+ terminalTextAnswer,
12939
13562
  options.triggerContextTokens,
12940
13563
  suppressContinuation,
12941
13564
  );
@@ -12958,11 +13581,17 @@ export class AgentSession {
12958
13581
  async signal => {
12959
13582
  await Promise.resolve();
12960
13583
  if (signal.aborted) return;
12961
- await this.#runAutoCompaction(reason, willRetry, true, true, { phase: options.phase });
13584
+ await this.#runAutoCompaction(reason, willRetry, true, true, {
13585
+ ...options,
13586
+ terminalTextAnswer,
13587
+ });
12962
13588
  },
12963
13589
  { generation },
12964
13590
  );
12965
- return COMPACTION_CHECK_DEFERRED_HANDOFF;
13591
+ return {
13592
+ ...COMPACTION_CHECK_DEFERRED_HANDOFF,
13593
+ continuationScheduled: shouldAutoContinue,
13594
+ };
12966
13595
  }
12967
13596
 
12968
13597
  // "overflow" forces context-full because the input itself is broken — a handoff
@@ -13029,10 +13658,14 @@ export class AgentSession {
13029
13658
  aborted: false,
13030
13659
  willRetry: false,
13031
13660
  });
13032
- const continuationScheduled = !autoCompactionSignal.aborted && reason !== "idle" && shouldAutoContinue;
13033
- if (continuationScheduled) {
13034
- this.#scheduleAutoContinuePrompt(generation);
13035
- }
13661
+ const continuationScheduled =
13662
+ !autoCompactionSignal.aborted &&
13663
+ this.#scheduleCompactionContinuation({
13664
+ generation,
13665
+ autoContinue: reason !== "idle" && shouldAutoContinue,
13666
+ terminalTextAnswer,
13667
+ suppressContinuation,
13668
+ });
13036
13669
  return {
13037
13670
  ...(continuationScheduled ? COMPACTION_CHECK_CONTINUATION : COMPACTION_CHECK_NONE),
13038
13671
  historyRewritten: true,
@@ -13521,20 +14154,13 @@ export class AgentSession {
13521
14154
  if (retryFits) {
13522
14155
  this.#scheduleAgentContinue({ delayMs: 100, generation });
13523
14156
  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,
14157
+ } else {
14158
+ continuationScheduled = this.#scheduleCompactionContinuation({
13534
14159
  generation,
13535
- shouldContinue: () => this.agent.hasQueuedMessages(),
14160
+ autoContinue: hasHeadroom && shouldAutoContinue,
14161
+ terminalTextAnswer,
14162
+ suppressContinuation,
13536
14163
  });
13537
- continuationScheduled = true;
13538
14164
  }
13539
14165
 
13540
14166
  if (deadEndWarning) {
@@ -13590,6 +14216,7 @@ export class AgentSession {
13590
14216
  willRetry: boolean,
13591
14217
  generation: number,
13592
14218
  autoContinue: boolean,
14219
+ terminalTextAnswer: boolean,
13593
14220
  triggerContextTokens?: number,
13594
14221
  suppressContinuation = false,
13595
14222
  ): Promise<CompactionCheckResult | "fallback"> {
@@ -13672,10 +14299,6 @@ export class AgentSession {
13672
14299
  });
13673
14300
 
13674
14301
  let continuationScheduled = false;
13675
- if (!willRetry && reason !== "idle" && autoContinue) {
13676
- this.#scheduleAutoContinuePrompt(generation);
13677
- continuationScheduled = true;
13678
- }
13679
14302
  if (willRetry) {
13680
14303
  // The shake rebuild replays every entry, so a trailing error/length
13681
14304
  // assistant from the failed turn re-enters agent state — drop it before
@@ -13691,13 +14314,13 @@ export class AgentSession {
13691
14314
  }
13692
14315
  this.#scheduleAgentContinue({ delayMs: 100, generation });
13693
14316
  continuationScheduled = true;
13694
- } else if (!suppressContinuation && this.agent.hasQueuedMessages()) {
13695
- this.#scheduleAgentContinue({
13696
- delayMs: 100,
14317
+ } else {
14318
+ continuationScheduled = this.#scheduleCompactionContinuation({
13697
14319
  generation,
13698
- shouldContinue: () => this.agent.hasQueuedMessages(),
14320
+ autoContinue: reason !== "idle" && autoContinue,
14321
+ terminalTextAnswer,
14322
+ suppressContinuation,
13699
14323
  });
13700
- continuationScheduled = true;
13701
14324
  }
13702
14325
  if (!reclaimed) {
13703
14326
  return willRetry && continuationScheduled
@@ -13851,6 +14474,11 @@ export class AgentSession {
13851
14474
  return stopType === "refusal" || stopType === "sensitive";
13852
14475
  }
13853
14476
 
14477
+ /** True when any registered model belongs to `provider`. */
14478
+ #hasProviderModels(provider: string): boolean {
14479
+ return this.#modelRegistry.getAll().some(model => model.provider === provider);
14480
+ }
14481
+
13854
14482
  #getRetryFallbackChains(): RetryFallbackChains {
13855
14483
  const configuredChains = this.settings.get("retry.fallbackChains");
13856
14484
  if (!configuredChains || typeof configuredChains !== "object") return {};
@@ -13881,8 +14509,8 @@ export class AgentSession {
13881
14509
  const keyKind = isRetryFallbackModelKey(key) ? "model" : "role";
13882
14510
  if (keyKind === "model") {
13883
14511
  if (isRetryFallbackWildcardKey(key)) {
13884
- const provider = key.slice(0, -2);
13885
- if (!this.#modelRegistry.getAll().some(model => model.provider === provider)) {
14512
+ const { provider } = parseRetryFallbackWildcard(key, p => this.#hasProviderModels(p));
14513
+ if (!this.#hasProviderModels(provider)) {
13886
14514
  const msg = `retry.fallbackChains wildcard key references unknown provider: ${key}`;
13887
14515
  logger.warn(msg);
13888
14516
  this.configWarnings.push(msg);
@@ -13914,8 +14542,8 @@ export class AgentSession {
13914
14542
  continue;
13915
14543
  }
13916
14544
  if (isRetryFallbackWildcardKey(selectorStr)) {
13917
- const provider = selectorStr.slice(0, -2);
13918
- if (!this.#modelRegistry.getAll().some(model => model.provider === provider)) {
14545
+ const { provider } = parseRetryFallbackWildcard(selectorStr, p => this.#hasProviderModels(p));
14546
+ if (!this.#hasProviderModels(provider)) {
13919
14547
  const msg = `Fallback chain for ${keyKind} '${key}' references unknown provider: ${selectorStr}`;
13920
14548
  logger.warn(msg);
13921
14549
  this.configWarnings.push(msg);
@@ -13974,13 +14602,16 @@ export class AgentSession {
13974
14602
  * Model-oriented keys win over roles so a chain follows the model across
13975
14603
  * role reassignments.
13976
14604
  */
13977
- #resolveRetryFallbackRole(currentSelector: string): string | undefined {
14605
+ #resolveRetryFallbackRole(
14606
+ currentSelector: string,
14607
+ currentModel: Model | null | undefined = this.model,
14608
+ ): string | undefined {
13978
14609
  const parsedCurrent = parseRetryFallbackSelector(currentSelector, this.#modelRegistry);
13979
14610
  if (!parsedCurrent) return undefined;
13980
14611
  const chains = this.#getRetryFallbackChains();
13981
14612
  const currentBaseSelector = formatRetryFallbackBaseSelector(parsedCurrent);
13982
- const currentPlainSelector = this.model
13983
- ? formatModelSelectorValue(formatModelString(this.model), parsedCurrent.thinkingLevel)
14613
+ const currentPlainSelector = currentModel
14614
+ ? formatModelSelectorValue(formatModelString(currentModel), parsedCurrent.thinkingLevel)
13984
14615
  : undefined;
13985
14616
  const currentPlainBaseSelector =
13986
14617
  currentPlainSelector && currentPlainSelector !== currentSelector
@@ -14006,9 +14637,22 @@ export class AgentSession {
14006
14637
  for (const key of exactModelKeys) {
14007
14638
  if (matchesCurrent(this.#getRetryFallbackPrimarySelector(key))) return key;
14008
14639
  }
14009
- // 2. Provider wildcard (`provider/*`) any active model of this provider.
14010
- const wildcardKey = `${parsedCurrent.provider}/*`;
14011
- if (Array.isArray(chains[wildcardKey])) return wildcardKey;
14640
+ // 2. Provider wildcardsan id-prefixed key (`openrouter/google/*`)
14641
+ // beats the plain `provider/*` key for ids under its prefix.
14642
+ let wildcardMatch: string | undefined;
14643
+ let wildcardPrefixLength = -1;
14644
+ for (const key in chains) {
14645
+ if (!isRetryFallbackWildcardKey(key) || !Array.isArray(chains[key])) continue;
14646
+ const { provider, idPrefix } = parseRetryFallbackWildcard(key, p => this.#hasProviderModels(p));
14647
+ if (provider !== parsedCurrent.provider) continue;
14648
+ if (idPrefix !== undefined && !parsedCurrent.id.startsWith(`${idPrefix}/`)) continue;
14649
+ const prefixLength = idPrefix === undefined ? 0 : idPrefix.length;
14650
+ if (prefixLength > wildcardPrefixLength) {
14651
+ wildcardMatch = key;
14652
+ wildcardPrefixLength = prefixLength;
14653
+ }
14654
+ }
14655
+ if (wildcardMatch) return wildcardMatch;
14012
14656
  // 3. Role keys — matched by the role's currently-assigned model.
14013
14657
  for (const key of roleKeys) {
14014
14658
  if (matchesCurrent(this.#getRetryFallbackPrimarySelector(key))) return key;
@@ -14027,9 +14671,11 @@ export class AgentSession {
14027
14671
 
14028
14672
  /**
14029
14673
  * 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.
14674
+ * model's id and swaps the provider (google-antigravity/x → google/x); an
14675
+ * id-prefixed `provider/prefix/*` entry re-prefixes the failing model's
14676
+ * bare id instead (openrouter/google/* : google-antigravity/x →
14677
+ * openrouter/google/x). Ids the target provider lacks are skipped by the
14678
+ * candidate loop's registry lookup.
14033
14679
  */
14034
14680
  #parseRetryFallbackChainEntry(
14035
14681
  entry: string,
@@ -14037,8 +14683,23 @@ export class AgentSession {
14037
14683
  ): RetryFallbackSelector | undefined {
14038
14684
  if (isRetryFallbackWildcardKey(entry)) {
14039
14685
  if (!current) return undefined;
14040
- const provider = entry.slice(0, -2);
14041
- return { raw: `${provider}/${current.id}`, provider, id: current.id, thinkingLevel: undefined };
14686
+ const { provider, idPrefix } = parseRetryFallbackWildcard(entry, p => this.#hasProviderModels(p));
14687
+ const bareId = current.id.slice(current.id.lastIndexOf("/") + 1);
14688
+ let id: string;
14689
+ if (idPrefix !== undefined) {
14690
+ id = `${idPrefix}/${bareId}`;
14691
+ } else if (
14692
+ bareId !== current.id &&
14693
+ !this.#modelRegistry.find(provider, current.id) &&
14694
+ this.#modelRegistry.find(provider, bareId)
14695
+ ) {
14696
+ // Aggregator → direct: the failing id carries a vendor prefix the
14697
+ // target provider does not use (openrouter/google/x → google-vertex/x).
14698
+ id = bareId;
14699
+ } else {
14700
+ id = current.id;
14701
+ }
14702
+ return { raw: `${provider}/${id}`, provider, id, thinkingLevel: undefined };
14042
14703
  }
14043
14704
  return parseRetryFallbackSelector(entry, this.#modelRegistry);
14044
14705
  }
@@ -14071,7 +14732,11 @@ export class AgentSession {
14071
14732
  return chain;
14072
14733
  }
14073
14734
 
14074
- #findRetryFallbackCandidates(role: string, currentSelector: string): RetryFallbackSelector[] {
14735
+ #findRetryFallbackCandidates(
14736
+ role: string,
14737
+ currentSelector: string,
14738
+ currentModel: Model | null | undefined = this.model,
14739
+ ): RetryFallbackSelector[] {
14075
14740
  let chain = this.#getRetryFallbackEffectiveChain(role, currentSelector);
14076
14741
  const parsedCurrent = parseRetryFallbackSelector(currentSelector, this.#modelRegistry);
14077
14742
  if (chain.length === 0 && role === "default" && parsedCurrent) {
@@ -14095,8 +14760,8 @@ export class AgentSession {
14095
14760
  if (chain.length <= 1) return [];
14096
14761
  const currentBaseSelector = parsedCurrent ? formatRetryFallbackBaseSelector(parsedCurrent) : undefined;
14097
14762
  const currentPlainSelector =
14098
- this.model && parsedCurrent
14099
- ? formatModelSelectorValue(formatModelString(this.model), parsedCurrent.thinkingLevel)
14763
+ currentModel && parsedCurrent
14764
+ ? formatModelSelectorValue(formatModelString(currentModel), parsedCurrent.thinkingLevel)
14100
14765
  : undefined;
14101
14766
  const currentPlainBaseSelector =
14102
14767
  parsedCurrent && currentPlainSelector && currentPlainSelector !== currentSelector
@@ -14706,17 +15371,187 @@ export class AgentSession {
14706
15371
  // Bash Execution
14707
15372
  // =========================================================================
14708
15373
 
14709
- async #saveBashOriginalArtifact(originalText: string): Promise<string | undefined> {
15374
+ async #saveBashOriginalArtifact(target: BashSessionTarget, originalText: string): Promise<string | undefined> {
14710
15375
  try {
14711
- return await this.sessionManager.saveArtifact(originalText, "bash-original");
15376
+ const destination = target.destination ?? (await target.pending);
15377
+ return await destination?.manager.saveArtifact(originalText, "bash-original");
14712
15378
  } catch {
14713
15379
  return undefined;
14714
15380
  }
14715
15381
  }
14716
15382
 
15383
+ #createBashMessage(
15384
+ command: string,
15385
+ result: BashResult,
15386
+ options?: { excludeFromContext?: boolean },
15387
+ ): BashExecutionMessage {
15388
+ const meta = outputMeta().truncationFromSummary(result, { direction: "tail" }).get();
15389
+ return {
15390
+ role: "bashExecution",
15391
+ command,
15392
+ output: result.output,
15393
+ exitCode: result.exitCode,
15394
+ cancelled: result.cancelled,
15395
+ truncated: result.truncated,
15396
+ meta,
15397
+ timestamp: Date.now(),
15398
+ excludeFromContext: options?.excludeFromContext,
15399
+ };
15400
+ }
15401
+
15402
+ #captureBashSessionTarget(): BashSessionTarget {
15403
+ this.#bashSessionTarget.refs++;
15404
+ return this.#bashSessionTarget;
15405
+ }
15406
+
15407
+ async #releaseBashSessionTarget(target: BashSessionTarget): Promise<void> {
15408
+ if (target.refs <= 0) throw new Error("Bash session target released more than once");
15409
+ target.refs--;
15410
+ if (target.refs === 0 && target.destination?.kind === "detached") {
15411
+ await target.destination.manager.close();
15412
+ }
15413
+ }
15414
+
15415
+ #appendBashMessage(destination: BashAppendDestination, message: BashExecutionMessage): void {
15416
+ switch (destination.kind) {
15417
+ case "current":
15418
+ this.agent.appendMessage(message);
15419
+ destination.manager.appendMessage(message);
15420
+ break;
15421
+ case "detached":
15422
+ destination.manager.appendMessage(message);
15423
+ break;
15424
+ case "branch":
15425
+ destination.parentId = destination.manager.appendMessageToBranch(message, destination.parentId);
15426
+ break;
15427
+ }
15428
+ }
15429
+
15430
+ async #appendOwnedBashMessage(target: BashSessionTarget, message: BashExecutionMessage): Promise<void> {
15431
+ try {
15432
+ const destination = target.destination ?? (await target.pending);
15433
+ if (!destination) throw new Error("Bash session target has no append destination");
15434
+ this.#appendBashMessage(destination, message);
15435
+ } finally {
15436
+ await this.#releaseBashSessionTarget(target);
15437
+ }
15438
+ }
15439
+
15440
+ async #recordBashResultForTarget(
15441
+ target: BashSessionTarget,
15442
+ command: string,
15443
+ result: BashResult,
15444
+ options?: { excludeFromContext?: boolean },
15445
+ ): Promise<void> {
15446
+ const message = this.#createBashMessage(command, result, options);
15447
+ if (this.isStreaming && target === this.#bashSessionTarget) {
15448
+ this.#pendingBashMessages.push({ target, message });
15449
+ return;
15450
+ }
15451
+ await this.#appendOwnedBashMessage(target, message);
15452
+ }
15453
+
15454
+ /** Run a leaf rewrite while retaining any in-flight bash on its originating branch. */
15455
+ #withBashBranchTransition<T>(mutate: () => T): T {
15456
+ const bashTransition = this.#beginBashSessionTransition();
15457
+ let branchTransitioned = false;
15458
+ try {
15459
+ const result = mutate();
15460
+ this.#markBashSessionTransition(bashTransition);
15461
+ branchTransitioned = true;
15462
+ return result;
15463
+ } finally {
15464
+ this.#finishBashSessionTransition(bashTransition, branchTransitioned);
15465
+ }
15466
+ }
15467
+
15468
+ /**
15469
+ * Snapshot the session/branch that owns any in-flight bash before a transition.
15470
+ * When an owner is still active, its target is detached to a clone so a failed
15471
+ * or intentionally dropped transition never redirects the late result.
15472
+ */
15473
+ #beginBashSessionTransition(options?: { persistDetached?: boolean }): BashSessionTransition {
15474
+ const oldTarget = this.#bashSessionTarget;
15475
+ let detachedManager: SessionManager | undefined;
15476
+ let resolveOld: ((destination: BashAppendDestination) => void) | undefined;
15477
+ if (oldTarget.refs > 0) {
15478
+ detachedManager = this.sessionManager.cloneCurrentSession({ persist: options?.persistDetached });
15479
+ const pendingOld = Promise.withResolvers<BashAppendDestination>();
15480
+ oldTarget.destination = undefined;
15481
+ oldTarget.pending = pendingOld.promise;
15482
+ resolveOld = pendingOld.resolve;
15483
+ }
15484
+
15485
+ const pendingNew = Promise.withResolvers<BashAppendDestination>();
15486
+ return {
15487
+ oldTarget,
15488
+ newTarget: {
15489
+ sessionId: this.sessionManager.getSessionId(),
15490
+ refs: 0,
15491
+ pending: pendingNew.promise,
15492
+ },
15493
+ oldSessionId: this.sessionManager.getSessionId(),
15494
+ oldSessionFile: this.sessionManager.getSessionFile(),
15495
+ oldLeafId: this.sessionManager.getLeafId(),
15496
+ detachedManager,
15497
+ resolveOld,
15498
+ resolveNew: pendingNew.resolve,
15499
+ };
15500
+ }
15501
+
15502
+ /** Adopt the transition's new target as the live bash owner. */
15503
+ #markBashSessionTransition(transition: BashSessionTransition): void {
15504
+ transition.newTarget.sessionId = this.sessionManager.getSessionId();
15505
+ this.#bashSessionTarget = transition.newTarget;
15506
+ }
15507
+
15508
+ /**
15509
+ * Resolve the pending append destinations opened by {@link #beginBashSessionTransition}.
15510
+ * On success the old owner keeps its original session/branch (same file → current or
15511
+ * branch destination; different file → detached clone); on failure both fall back to
15512
+ * the still-current manager and the clone is discarded.
15513
+ */
15514
+ #finishBashSessionTransition(transition: BashSessionTransition, success: boolean): void {
15515
+ const currentDestination: BashAppendDestination = { kind: "current", manager: this.sessionManager };
15516
+ let oldDestination: BashAppendDestination = currentDestination;
15517
+ if (success && transition.resolveOld) {
15518
+ const currentFile = this.sessionManager.getSessionFile();
15519
+ const sameFile =
15520
+ transition.oldSessionFile === currentFile ||
15521
+ (transition.oldSessionFile !== undefined &&
15522
+ currentFile !== undefined &&
15523
+ path.resolve(transition.oldSessionFile) === path.resolve(currentFile));
15524
+ const sameSession = transition.oldSessionId === this.sessionManager.getSessionId() && sameFile;
15525
+ if (sameSession) {
15526
+ oldDestination =
15527
+ transition.oldLeafId === this.sessionManager.getLeafId()
15528
+ ? currentDestination
15529
+ : { kind: "branch", manager: this.sessionManager, parentId: transition.oldLeafId };
15530
+ } else if (transition.detachedManager) {
15531
+ oldDestination = { kind: "detached", manager: transition.detachedManager };
15532
+ }
15533
+ }
15534
+
15535
+ if (transition.resolveOld) {
15536
+ transition.oldTarget.pending = undefined;
15537
+ transition.oldTarget.destination = oldDestination;
15538
+ transition.resolveOld(oldDestination);
15539
+ }
15540
+
15541
+ transition.newTarget.pending = undefined;
15542
+ transition.newTarget.destination = currentDestination;
15543
+ if (!success) transition.newTarget.sessionId = this.sessionManager.getSessionId();
15544
+ transition.resolveNew(currentDestination);
15545
+
15546
+ if (transition.detachedManager && (oldDestination.kind !== "detached" || transition.oldTarget.refs === 0)) {
15547
+ void transition.detachedManager.close().catch(error => {
15548
+ logger.warn("Failed to close detached bash session writer", { error: String(error) });
15549
+ });
15550
+ }
15551
+ }
15552
+
14717
15553
  /**
14718
- * Execute a bash command.
14719
- * Adds result to agent context and session.
15554
+ * Execute a bash command and retain the session/branch that owned its start.
14720
15555
  * @param command The bash command to execute
14721
15556
  * @param onChunk Optional streaming callback for output
14722
15557
  * @param options.excludeFromContext If true, command output won't be sent to LLM (!! prefix)
@@ -14727,72 +15562,72 @@ export class AgentSession {
14727
15562
  onChunk?: (chunk: string) => void,
14728
15563
  options?: { excludeFromContext?: boolean; useUserShell?: boolean },
14729
15564
  ): Promise<BashResult> {
15565
+ const target = this.#captureBashSessionTarget();
15566
+ let targetTransferred = false;
14730
15567
  const excludeFromContext = options?.excludeFromContext === true;
14731
15568
  const cwd = this.sessionManager.getCwd();
14732
15569
 
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;
15570
+ try {
15571
+ if (this.#extensionRunner?.hasHandlers("user_bash")) {
15572
+ const hookResult = await this.#extensionRunner.emitUserBash({
15573
+ type: "user_bash",
15574
+ command,
15575
+ excludeFromContext,
15576
+ cwd,
15577
+ });
15578
+ if (hookResult?.result) {
15579
+ targetTransferred = true;
15580
+ await this.#recordBashResultForTarget(target, command, hookResult.result, options);
15581
+ return hookResult.result;
15582
+ }
14743
15583
  }
14744
- }
14745
-
14746
- const abortController = new AbortController();
14747
- this.#bashAbortControllers.add(abortController);
14748
15584
 
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
- });
15585
+ const abortController = new AbortController();
15586
+ this.#bashAbortControllers.add(abortController);
15587
+ let result: BashResult;
15588
+ try {
15589
+ result = await executeBashCommand(command, {
15590
+ onChunk,
15591
+ signal: abortController.signal,
15592
+ sessionKey: target.sessionId,
15593
+ cwd,
15594
+ timeout: clampTimeout("bash") * 1000,
15595
+ onMinimizedSave: originalText => this.#saveBashOriginalArtifact(target, originalText),
15596
+ useUserShell: options?.useUserShell,
15597
+ });
15598
+ } finally {
15599
+ this.#bashAbortControllers.delete(abortController);
15600
+ }
14759
15601
 
14760
- this.recordBashResult(command, result, options);
15602
+ targetTransferred = true;
15603
+ await this.#recordBashResultForTarget(target, command, result, options);
14761
15604
  return result;
14762
15605
  } finally {
14763
- this.#bashAbortControllers.delete(abortController);
15606
+ if (!targetTransferred) await this.#releaseBashSessionTarget(target);
14764
15607
  }
14765
15608
  }
14766
15609
 
14767
- /**
14768
- * Record a bash execution result in session history.
14769
- * Used by executeBash and by extensions that handle bash execution themselves.
14770
- */
15610
+ /** Record a bash result supplied outside executeBash in the current ownership scope. */
14771
15611
  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);
15612
+ const target = this.#captureBashSessionTarget();
15613
+ const message = this.#createBashMessage(command, result, options);
15614
+ if (this.isStreaming && target === this.#bashSessionTarget) {
15615
+ this.#pendingBashMessages.push({ target, message });
15616
+ return;
15617
+ }
14792
15618
 
14793
- // Save to session
14794
- this.sessionManager.appendMessage(bashMessage);
15619
+ if (target.destination) {
15620
+ try {
15621
+ this.#appendBashMessage(target.destination, message);
15622
+ } finally {
15623
+ void this.#releaseBashSessionTarget(target);
15624
+ }
15625
+ return;
14795
15626
  }
15627
+
15628
+ void this.#appendOwnedBashMessage(target, message).catch(error => {
15629
+ logger.error("Failed to record bash result in its owning session", { error: String(error) });
15630
+ });
14796
15631
  }
14797
15632
 
14798
15633
  /**
@@ -14814,22 +15649,14 @@ export class AgentSession {
14814
15649
  return this.#pendingBashMessages.length > 0;
14815
15650
  }
14816
15651
 
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 {
15652
+ /** Flush pending bash messages after the active turn without changing their ownership. */
15653
+ async #flushPendingBashMessages(): Promise<void> {
14822
15654
  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
-
15655
+ const pending = this.#pendingBashMessages;
14832
15656
  this.#pendingBashMessages = [];
15657
+ for (const { target, message } of pending) {
15658
+ await this.#appendOwnedBashMessage(target, message);
15659
+ }
14833
15660
  }
14834
15661
 
14835
15662
  // =========================================================================
@@ -15422,9 +16249,11 @@ export class AgentSession {
15422
16249
  this.#disconnectFromAgent();
15423
16250
  await this.abort({ goalReason: "internal" });
15424
16251
 
16252
+ await this.#flushPendingBashMessages();
15425
16253
  // Flush pending writes before switching so restore snapshots reflect committed state.
15426
16254
  await this.sessionManager.flush();
15427
16255
  const previousSessionState = this.sessionManager.captureState();
16256
+ const bashTransition = this.#beginBashSessionTransition();
15428
16257
  // Only same-session reloads compare against the prior context to detect
15429
16258
  // rollback edits (`#didSessionMessagesChange` below). Building it for a
15430
16259
  // different-session switch is a pure waste — and on huge pre-fix sessions
@@ -15469,6 +16298,7 @@ export class AgentSession {
15469
16298
 
15470
16299
  try {
15471
16300
  await this.sessionManager.setSessionFile(sessionPath);
16301
+ this.#markBashSessionTransition(bashTransition);
15472
16302
  if (switchingToDifferentSession) {
15473
16303
  this.#freshProviderSessionId = undefined;
15474
16304
  this.#clearInheritedProviderPromptCacheKey();
@@ -15602,6 +16432,7 @@ export class AgentSession {
15602
16432
  error: String(error),
15603
16433
  });
15604
16434
  }
16435
+ this.#finishBashSessionTransition(bashTransition, true);
15605
16436
  return true;
15606
16437
  } catch (error) {
15607
16438
  this.sessionManager.restoreState(previousSessionState);
@@ -15633,6 +16464,7 @@ export class AgentSession {
15633
16464
  this.#syncTodoPhasesFromBranch();
15634
16465
  this.#resetAllAdvisorRuntimes();
15635
16466
  this.#reconnectToAgent();
16467
+ this.#finishBashSessionTransition(bashTransition, false);
15636
16468
  throw error;
15637
16469
  }
15638
16470
  }
@@ -15678,14 +16510,25 @@ export class AgentSession {
15678
16510
  this.#pendingNextTurnMessages = [];
15679
16511
  this.#scheduledHiddenNextTurnGeneration = undefined;
15680
16512
 
16513
+ await this.#flushPendingBashMessages();
15681
16514
  // Flush pending writes before branching
15682
16515
  await this.sessionManager.flush();
16516
+ const bashTransition = this.#beginBashSessionTransition();
15683
16517
  this.#cancelOwnAsyncJobs();
16518
+ this.#abortAutolearnCapture();
16519
+ await this.#drainAutolearnCapture();
15684
16520
 
15685
- if (!selectedEntry.parentId) {
15686
- await this.sessionManager.newSession({ parentSession: previousSessionFile });
15687
- } else {
15688
- this.sessionManager.createBranchedSession(selectedEntry.parentId);
16521
+ let sessionTransitioned = false;
16522
+ try {
16523
+ if (!selectedEntry.parentId) {
16524
+ await this.sessionManager.newSession({ parentSession: previousSessionFile });
16525
+ } else {
16526
+ this.sessionManager.createBranchedSession(selectedEntry.parentId);
16527
+ }
16528
+ this.#markBashSessionTransition(bashTransition);
16529
+ sessionTransitioned = true;
16530
+ } finally {
16531
+ this.#finishBashSessionTransition(bashTransition, sessionTransitioned);
15689
16532
  }
15690
16533
  this.#rehydrateCheckpointRewindState();
15691
16534
  this.#syncTodoPhasesFromBranch();
@@ -15769,10 +16612,21 @@ export class AgentSession {
15769
16612
  await this.abort({ goalReason: "internal", reason: "branching /btw" });
15770
16613
  this.agent.replaceQueues([], []);
15771
16614
  }
16615
+ await this.#flushPendingBashMessages();
15772
16616
  await this.sessionManager.flush();
16617
+ const bashTransition = this.#beginBashSessionTransition();
15773
16618
  this.#cancelOwnAsyncJobs();
16619
+ this.#abortAutolearnCapture();
16620
+ await this.#drainAutolearnCapture();
15774
16621
 
15775
- this.sessionManager.createBranchedSession(leafId);
16622
+ let sessionTransitioned = false;
16623
+ try {
16624
+ this.sessionManager.createBranchedSession(leafId);
16625
+ this.#markBashSessionTransition(bashTransition);
16626
+ sessionTransitioned = true;
16627
+ } finally {
16628
+ this.#finishBashSessionTransition(bashTransition, sessionTransitioned);
16629
+ }
15776
16630
 
15777
16631
  this.#rehydrateCheckpointRewindState();
15778
16632
  this.sessionManager.appendMessage({
@@ -15828,6 +16682,7 @@ export class AgentSession {
15828
16682
  /** Raw session context built during navigation — pass to renderInitialMessages to skip a second O(N) walk. */
15829
16683
  sessionContext?: SessionContext;
15830
16684
  }> {
16685
+ await this.#flushPendingBashMessages();
15831
16686
  const oldLeafId = this.sessionManager.getLeafId();
15832
16687
 
15833
16688
  // No-op if already at target
@@ -15955,18 +16810,28 @@ export class AgentSession {
15955
16810
 
15956
16811
  // Switch leaf (with or without summary)
15957
16812
  // Summary is attached at the navigation target position (newLeafId), not the old branch
16813
+ const bashTransition = this.#beginBashSessionTransition();
15958
16814
  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);
16815
+ let branchTransitioned = false;
16816
+ try {
16817
+ if (summaryText) {
16818
+ // Create summary at target position (can be null for root)
16819
+ const summaryId = this.sessionManager.branchWithSummary(
16820
+ newLeafId,
16821
+ summaryText,
16822
+ summaryDetails,
16823
+ fromExtension,
16824
+ );
16825
+ summaryEntry = this.sessionManager.getEntry(summaryId) as BranchSummaryEntry;
16826
+ } else if (newLeafId === null) {
16827
+ this.sessionManager.resetLeaf();
16828
+ } else {
16829
+ this.sessionManager.branch(newLeafId);
16830
+ }
16831
+ this.#markBashSessionTransition(bashTransition);
16832
+ branchTransitioned = true;
16833
+ } finally {
16834
+ this.#finishBashSessionTransition(bashTransition, branchTransitioned);
15970
16835
  }
15971
16836
 
15972
16837
  // Update agent state — build display context to populate agent messages.
@@ -16709,29 +17574,73 @@ export class AgentSession {
16709
17574
  return this.#advisors[0]?.agent;
16710
17575
  }
16711
17576
 
17577
+ /**
17578
+ * Lightweight advisor status for the status line: returns just the configured
17579
+ * flag and per-advisor name/status without computing token/cost breakdowns.
17580
+ * Avoids re-tokenizing the advisor transcript on every render frame.
17581
+ */
17582
+ getAdvisorStatusOverview(): { configured: boolean; advisors: { name: string; status: AdvisorRuntimeStatus }[] } {
17583
+ // Override stale map entries with live runtime status: failureNotified/quotaExhausted
17584
+ // clear on reset() but #advisorStatuses lags until the next build.
17585
+ const liveStatusBySlug = new Map<string, AdvisorRuntimeStatus>();
17586
+ for (const a of this.#advisors) {
17587
+ liveStatusBySlug.set(
17588
+ a.slug,
17589
+ a.runtime.quotaExhausted ? "quota_exhausted" : a.runtime.failureNotified ? "error" : "running",
17590
+ );
17591
+ }
17592
+ const advisors = [...this.#advisorStatuses.entries()].map(([slug, { name, status }]) => ({
17593
+ name,
17594
+ status: liveStatusBySlug.get(slug) ?? status,
17595
+ }));
17596
+ return { configured: this.#advisorEnabled, advisors };
17597
+ }
16712
17598
  /**
16713
17599
  * Return structured advisor stats for the status command and TUI panel.
16714
17600
  */
16715
17601
  getAdvisorStats(): AdvisorStats {
16716
17602
  const configured = this.#advisorEnabled;
16717
- const advisors = this.#advisors.map(a => this.#computeAdvisorStat(a));
16718
- if (advisors.length === 0) {
17603
+ const liveAdvisors = this.#advisors.map(a => this.#computeAdvisorStat(a));
17604
+ // Build the complete roster from #advisorStatuses, which already has the
17605
+ // correct de-duped slugs as keys. Live advisors (from #advisors) carry full
17606
+ // token/cost data; disabled/no-model/quota-exhausted advisors appear as
17607
+ // skeleton entries with just name + status so the status line renders a dot.
17608
+ const liveStatBySlug = new Map(this.#advisors.map((a, i) => [a.slug, liveAdvisors[i]]));
17609
+ const roster: PerAdvisorStat[] = [];
17610
+ for (const [slug, entry] of this.#advisorStatuses) {
17611
+ const live = liveStatBySlug.get(slug);
17612
+ if (live) {
17613
+ roster.push(live);
17614
+ } else {
17615
+ roster.push({
17616
+ name: entry.name,
17617
+ status: entry.status,
17618
+ contextWindow: 0,
17619
+ contextTokens: 0,
17620
+ tokens: { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
17621
+ cost: 0,
17622
+ messages: { user: 0, assistant: 0, total: 0 },
17623
+ });
17624
+ }
17625
+ }
17626
+ const active = liveAdvisors.length > 0;
17627
+ if (liveAdvisors.length === 0) {
16719
17628
  return {
16720
17629
  configured,
16721
- active: false,
17630
+ active,
16722
17631
  contextWindow: 0,
16723
17632
  contextTokens: 0,
16724
17633
  tokens: { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
16725
17634
  cost: 0,
16726
17635
  messages: { user: 0, assistant: 0, total: 0 },
16727
- advisors: [],
17636
+ advisors: roster,
16728
17637
  };
16729
17638
  }
16730
17639
  const tokens = { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0, total: 0 };
16731
17640
  const messages = { user: 0, assistant: 0, total: 0 };
16732
17641
  let cost = 0;
16733
17642
  let contextTokens = 0;
16734
- for (const a of advisors) {
17643
+ for (const a of liveAdvisors) {
16735
17644
  tokens.input += a.tokens.input;
16736
17645
  tokens.output += a.tokens.output;
16737
17646
  tokens.reasoning += a.tokens.reasoning;
@@ -16748,14 +17657,14 @@ export class AgentSession {
16748
17657
  // first advisor's so the legacy status line stays byte-identical.
16749
17658
  return {
16750
17659
  configured,
16751
- active: true,
16752
- model: advisors[0].model,
16753
- contextWindow: advisors[0].contextWindow,
17660
+ active,
17661
+ model: liveAdvisors[0].model,
17662
+ contextWindow: liveAdvisors[0].contextWindow,
16754
17663
  contextTokens,
16755
17664
  tokens,
16756
17665
  cost,
16757
17666
  messages,
16758
- advisors,
17667
+ advisors: roster,
16759
17668
  };
16760
17669
  }
16761
17670
 
@@ -16789,12 +17698,18 @@ export class AgentSession {
16789
17698
  }
16790
17699
  return {
16791
17700
  name: advisor.name,
17701
+ status: advisor.runtime.quotaExhausted
17702
+ ? "quota_exhausted"
17703
+ : advisor.runtime.failureNotified
17704
+ ? "error"
17705
+ : "running",
16792
17706
  model,
16793
17707
  contextWindow: model.contextWindow ?? 0,
16794
17708
  contextTokens,
16795
17709
  tokens: { input, output, reasoning, cacheRead, cacheWrite, total: totalTokens },
16796
17710
  cost,
16797
17711
  messages: { user, assistant, total: messages.length },
17712
+ sessionId: advisor.agent.sessionId,
16798
17713
  };
16799
17714
  }
16800
17715
 
@@ -16803,13 +17718,18 @@ export class AgentSession {
16803
17718
  */
16804
17719
  formatAdvisorStatus(): string {
16805
17720
  const stats = this.getAdvisorStats();
16806
- if (!stats.active) {
17721
+ if (!stats.active && stats.advisors.length === 0) {
16807
17722
  return stats.configured
16808
17723
  ? "Advisor setting is enabled, but no model is assigned to the 'advisor' role."
16809
17724
  : "Advisor is disabled.";
16810
17725
  }
16811
17726
  if (stats.advisors.length <= 1) {
16812
17727
  const s = stats.advisors[0];
17728
+ if (s && s.status === "no_model") {
17729
+ return stats.configured
17730
+ ? "Advisor setting is enabled, but no model is assigned to the 'advisor' role."
17731
+ : "Advisor is disabled.";
17732
+ }
16813
17733
  const contextLine =
16814
17734
  s.contextWindow > 0
16815
17735
  ? `Context: ${s.contextTokens.toLocaleString()} / ${s.contextWindow.toLocaleString()} tokens (${Math.round((s.contextTokens / s.contextWindow) * 100)}%)`
@@ -16818,6 +17738,7 @@ export class AgentSession {
16818
17738
  if (s.tokens.cacheRead > 0) spendParts.push(`${s.tokens.cacheRead.toLocaleString()} cache read`);
16819
17739
  if (s.tokens.cacheWrite > 0) spendParts.push(`${s.tokens.cacheWrite.toLocaleString()} cache write`);
16820
17740
  const spendLine = `Spend: ${spendParts.join(", ")}, $${s.cost.toFixed(4)}`;
17741
+ if (!s.model || s.status !== "running") return `Advisor "${s.name}" is ${s.status.replace("_", " ")}.`;
16821
17742
  return `Advisor is enabled (${s.model.provider}/${s.model.id}). ${contextLine}. ${spendLine}.`;
16822
17743
  }
16823
17744
  const lines = [`Advisors enabled (${stats.advisors.length}):`];
@@ -16826,7 +17747,9 @@ export class AgentSession {
16826
17747
  s.contextWindow > 0
16827
17748
  ? `${s.contextTokens.toLocaleString()} / ${s.contextWindow.toLocaleString()} (${Math.round((s.contextTokens / s.contextWindow) * 100)}%)`
16828
17749
  : `${s.contextTokens.toLocaleString()}`;
16829
- lines.push(` • ${s.name} (${s.model.provider}/${s.model.id}) — context ${ctx} tokens, $${s.cost.toFixed(4)}`);
17750
+ lines.push(
17751
+ ` • ${s.name}${s.model && s.status === "running" ? ` (${s.model.provider}/${s.model.id})` : ` [${s.status}]`} — context ${ctx} tokens, $${s.cost.toFixed(4)}`,
17752
+ );
16830
17753
  }
16831
17754
  lines.push(
16832
17755
  `Totals: ${stats.tokens.input.toLocaleString()} input, ${stats.tokens.output.toLocaleString()} output, $${stats.cost.toFixed(4)}.`,
@@ -16835,37 +17758,51 @@ export class AgentSession {
16835
17758
  }
16836
17759
 
16837
17760
  /**
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.
17761
+ * Estimate the advisor's current context tokens. A successful provider usage
17762
+ * after the latest advisor compaction is ground truth for the prompt plus its
17763
+ * generated output; only messages after that anchor are estimated. Usage from
17764
+ * retained pre-compaction messages is stale and must not immediately retrigger
17765
+ * maintenance on the newly compacted context.
16842
17766
  */
16843
17767
  #estimateAdvisorContextTokens(messages: AgentMessage[]): number {
16844
- let lastUsageIndex: number | null = null;
16845
- let lastUsage: AssistantMessage["usage"] | undefined;
17768
+ let usageAnchorStartIndex = 0;
16846
17769
  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
- }
17770
+ const message = messages[i];
17771
+ if (message.role !== "compactionSummary") continue;
17772
+ const advisorSummary = message as AdvisorCompactionSummaryMessage;
17773
+ // Advisor summaries created before this runtime-only boundary existed have
17774
+ // no trustworthy way to distinguish retained from newly appended messages.
17775
+ // Conservatively ignore every current assistant until the next compaction.
17776
+ usageAnchorStartIndex = advisorSummary.advisorUsageAnchorStartIndex ?? messages.length;
17777
+ break;
17778
+ }
17779
+
17780
+ let lastUsageIndex: number | undefined;
17781
+ let lastUsage: AssistantMessage["usage"] | undefined;
17782
+ for (let i = messages.length - 1; i >= usageAnchorStartIndex; i--) {
17783
+ const message = messages[i];
17784
+ if (message.role !== "assistant") continue;
17785
+ const assistant = message as AssistantMessage;
17786
+ if (assistant.stopReason !== "aborted" && assistant.stopReason !== "error" && assistant.usage) {
17787
+ lastUsage = assistant.usage;
17788
+ lastUsageIndex = i;
17789
+ break;
16855
17790
  }
16856
17791
  }
16857
- if (!lastUsage || lastUsageIndex === null) {
17792
+
17793
+ const estimateOptions = { excludeEncryptedReasoning: true } as const;
17794
+ if (!lastUsage || lastUsageIndex === undefined) {
16858
17795
  let estimated = 0;
16859
17796
  for (const message of messages) {
16860
- estimated += estimateTokens(message);
17797
+ estimated += estimateTokens(message, estimateOptions);
16861
17798
  }
16862
17799
  return estimated;
16863
17800
  }
16864
17801
  let trailingTokens = 0;
16865
17802
  for (let i = lastUsageIndex + 1; i < messages.length; i++) {
16866
- trailingTokens += estimateTokens(messages[i]);
17803
+ trailingTokens += estimateTokens(messages[i], estimateOptions);
16867
17804
  }
16868
- return calculatePromptTokens(lastUsage) + trailingTokens;
17805
+ return calculateContextTokens(lastUsage) + trailingTokens;
16869
17806
  }
16870
17807
 
16871
17808
  /**