@oh-my-pi/pi-coding-agent 16.2.2 → 16.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/cli.js +3624 -3568
  3. package/dist/types/advisor/__tests__/config.test.d.ts +1 -0
  4. package/dist/types/advisor/advise-tool.d.ts +8 -4
  5. package/dist/types/advisor/config.d.ts +88 -0
  6. package/dist/types/advisor/index.d.ts +1 -0
  7. package/dist/types/advisor/transcript-recorder.d.ts +13 -2
  8. package/dist/types/advisor/watchdog.d.ts +20 -0
  9. package/dist/types/collab/guest.d.ts +29 -0
  10. package/dist/types/config/settings-schema.d.ts +81 -0
  11. package/dist/types/debug/log-viewer.d.ts +1 -0
  12. package/dist/types/debug/raw-sse.d.ts +1 -0
  13. package/dist/types/edit/hashline/diff.d.ts +0 -11
  14. package/dist/types/extensibility/tool-event-input.d.ts +7 -0
  15. package/dist/types/extensibility/utils.d.ts +12 -0
  16. package/dist/types/mcp/transports/index.d.ts +1 -0
  17. package/dist/types/mcp/transports/sse.d.ts +20 -0
  18. package/dist/types/modes/components/advisor-config.d.ts +59 -0
  19. package/dist/types/modes/components/index.d.ts +1 -0
  20. package/dist/types/modes/components/model-selector.d.ts +9 -1
  21. package/dist/types/modes/components/settings-selector.d.ts +1 -0
  22. package/dist/types/modes/components/status-line/component.d.ts +30 -1
  23. package/dist/types/modes/components/status-line/types.d.ts +13 -1
  24. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  25. package/dist/types/modes/interactive-mode.d.ts +10 -4
  26. package/dist/types/modes/skill-command.d.ts +32 -0
  27. package/dist/types/modes/types.d.ts +7 -2
  28. package/dist/types/session/agent-session.d.ts +58 -10
  29. package/dist/types/session/indexed-session-storage.d.ts +7 -1
  30. package/dist/types/session/messages.d.ts +26 -0
  31. package/dist/types/session/messages.test.d.ts +1 -0
  32. package/dist/types/session/session-entries.d.ts +31 -3
  33. package/dist/types/session/session-history-format.d.ts +6 -0
  34. package/dist/types/session/session-loader.d.ts +9 -1
  35. package/dist/types/session/session-manager.d.ts +6 -4
  36. package/dist/types/session/session-storage.d.ts +11 -0
  37. package/dist/types/session/session-title-slot.d.ts +19 -0
  38. package/dist/types/ssh/connection-manager.d.ts +47 -0
  39. package/dist/types/ssh/utils.d.ts +16 -0
  40. package/dist/types/task/executor.d.ts +3 -16
  41. package/dist/types/task/render.d.ts +0 -5
  42. package/dist/types/task/renderer.d.ts +13 -0
  43. package/dist/types/task/types.d.ts +16 -0
  44. package/dist/types/task/yield-assembly.d.ts +28 -0
  45. package/dist/types/tiny/text.d.ts +8 -0
  46. package/dist/types/tools/render-utils.d.ts +2 -0
  47. package/dist/types/tools/review.d.ts +6 -4
  48. package/dist/types/tools/ssh.d.ts +1 -1
  49. package/dist/types/tools/todo.d.ts +6 -0
  50. package/dist/types/tools/yield.d.ts +8 -3
  51. package/dist/types/utils/thinking-display.d.ts +4 -0
  52. package/package.json +12 -12
  53. package/src/advisor/__tests__/advisor.test.ts +242 -10
  54. package/src/advisor/__tests__/config.test.ts +173 -0
  55. package/src/advisor/advise-tool.ts +11 -6
  56. package/src/advisor/config.ts +256 -0
  57. package/src/advisor/index.ts +1 -0
  58. package/src/advisor/runtime.ts +12 -2
  59. package/src/advisor/transcript-recorder.ts +25 -2
  60. package/src/advisor/watchdog.ts +57 -31
  61. package/src/autoresearch/index.ts +7 -2
  62. package/src/cli/gc-cli.ts +17 -10
  63. package/src/collab/guest.ts +43 -7
  64. package/src/config/model-registry.ts +80 -18
  65. package/src/config/settings-schema.ts +77 -0
  66. package/src/debug/index.ts +32 -7
  67. package/src/debug/log-viewer.ts +111 -53
  68. package/src/debug/raw-sse.ts +68 -48
  69. package/src/discovery/codex.ts +13 -5
  70. package/src/edit/hashline/diff.ts +57 -4
  71. package/src/eval/js/shared/local-module-loader.ts +23 -1
  72. package/src/export/html/template.js +13 -7
  73. package/src/extensibility/extensions/loader.ts +5 -3
  74. package/src/extensibility/extensions/wrapper.ts +9 -3
  75. package/src/extensibility/hooks/loader.ts +3 -3
  76. package/src/extensibility/hooks/tool-wrapper.ts +13 -4
  77. package/src/extensibility/plugins/manager.ts +2 -1
  78. package/src/extensibility/tool-event-input.ts +23 -0
  79. package/src/extensibility/utils.ts +74 -0
  80. package/src/internal-urls/docs-index.generated.txt +1 -1
  81. package/src/mcp/client.ts +3 -1
  82. package/src/mcp/manager.ts +12 -5
  83. package/src/mcp/transports/index.ts +1 -0
  84. package/src/mcp/transports/sse.ts +377 -0
  85. package/src/memories/index.ts +15 -6
  86. package/src/modes/components/advisor-config.ts +555 -0
  87. package/src/modes/components/advisor-message.ts +9 -2
  88. package/src/modes/components/agent-hub.ts +9 -4
  89. package/src/modes/components/index.ts +2 -0
  90. package/src/modes/components/model-selector.ts +79 -48
  91. package/src/modes/components/settings-selector.ts +1 -0
  92. package/src/modes/components/status-line/component.ts +144 -5
  93. package/src/modes/components/status-line/segments.ts +46 -21
  94. package/src/modes/components/status-line/types.ts +13 -1
  95. package/src/modes/components/tool-execution.ts +47 -6
  96. package/src/modes/controllers/command-controller.ts +23 -2
  97. package/src/modes/controllers/event-controller.ts +106 -0
  98. package/src/modes/controllers/extension-ui-controller.ts +1 -1
  99. package/src/modes/controllers/input-controller.ts +61 -61
  100. package/src/modes/controllers/selector-controller.ts +100 -9
  101. package/src/modes/interactive-mode.ts +65 -5
  102. package/src/modes/skill-command.ts +116 -0
  103. package/src/modes/types.ts +7 -2
  104. package/src/modes/utils/ui-helpers.ts +41 -23
  105. package/src/prompts/agents/reviewer.md +11 -10
  106. package/src/prompts/review-custom-request.md +1 -2
  107. package/src/prompts/review-request.md +1 -2
  108. package/src/prompts/system/interrupted-thinking.md +7 -0
  109. package/src/prompts/system/recap-user.md +9 -0
  110. package/src/prompts/system/subagent-system-prompt.md +8 -5
  111. package/src/prompts/system/subagent-yield-reminder.md +6 -5
  112. package/src/prompts/system/system-prompt.md +0 -1
  113. package/src/prompts/tools/irc.md +2 -2
  114. package/src/prompts/tools/read.md +2 -2
  115. package/src/sdk.ts +28 -24
  116. package/src/session/agent-session.ts +867 -428
  117. package/src/session/indexed-session-storage.ts +86 -13
  118. package/src/session/messages.test.ts +125 -0
  119. package/src/session/messages.ts +172 -9
  120. package/src/session/redis-session-storage.ts +49 -2
  121. package/src/session/session-entries.ts +39 -2
  122. package/src/session/session-history-format.ts +29 -2
  123. package/src/session/session-listing.ts +54 -24
  124. package/src/session/session-loader.ts +66 -3
  125. package/src/session/session-manager.ts +113 -19
  126. package/src/session/session-persistence.ts +95 -1
  127. package/src/session/session-storage.ts +36 -0
  128. package/src/session/session-title-slot.ts +141 -0
  129. package/src/session/sql-session-storage.ts +71 -11
  130. package/src/slash-commands/builtin-registry.ts +16 -3
  131. package/src/ssh/__tests__/connection-manager-args.test.ts +123 -1
  132. package/src/ssh/__tests__/file-transfer-posix-guard.test.ts +55 -18
  133. package/src/ssh/connection-manager.ts +139 -12
  134. package/src/ssh/file-transfer.ts +23 -18
  135. package/src/ssh/ssh-executor.ts +2 -13
  136. package/src/ssh/utils.ts +19 -0
  137. package/src/task/executor.ts +21 -23
  138. package/src/task/render.ts +162 -20
  139. package/src/task/renderer.ts +14 -0
  140. package/src/task/types.ts +17 -0
  141. package/src/task/yield-assembly.ts +207 -0
  142. package/src/tiny/text.ts +23 -0
  143. package/src/tools/ask.ts +55 -4
  144. package/src/tools/render-utils.ts +2 -0
  145. package/src/tools/renderers.ts +8 -2
  146. package/src/tools/review.ts +17 -7
  147. package/src/tools/ssh.ts +8 -4
  148. package/src/tools/todo.ts +17 -1
  149. package/src/tools/yield.ts +140 -31
  150. package/src/utils/thinking-display.ts +15 -0
@@ -130,18 +130,22 @@ import {
130
130
  } from "@oh-my-pi/pi-utils";
131
131
  import * as snapcompact from "@oh-my-pi/snapcompact";
132
132
  import {
133
+ ADVISOR_DEFAULT_TOOL_NAMES,
133
134
  AdviseTool,
134
135
  type AdvisorAgent,
136
+ type AdvisorConfig,
135
137
  AdvisorEmissionGuard,
136
138
  type AdvisorMessageDetails,
137
139
  type AdvisorNote,
138
140
  AdvisorRuntime,
139
141
  type AdvisorSeverity,
140
142
  AdvisorTranscriptRecorder,
143
+ advisorTranscriptFilename,
141
144
  formatAdvisorBatchContent,
142
145
  isAdvisorInterruptImmuneTurnActive,
143
146
  isInterruptingSeverity,
144
147
  resolveAdvisorDeliveryChannel,
148
+ slugifyAdvisorName,
145
149
  } from "../advisor";
146
150
  import { type AsyncJob, type AsyncJobDeliveryState, AsyncJobManager } from "../async";
147
151
  import { classifyDifficulty } from "../auto-thinking/classifier";
@@ -232,6 +236,7 @@ import eagerTaskPrompt from "../prompts/system/eager-task.md" with { type: "text
232
236
  import eagerTodoPrompt from "../prompts/system/eager-todo.md" with { type: "text" };
233
237
  import emptyStopRetryTemplate from "../prompts/system/empty-stop-retry.md" with { type: "text" };
234
238
  import geminiToolReminderTemplate from "../prompts/system/gemini-tool-call-reminder.md" with { type: "text" };
239
+ import interruptedThinkingTemplate from "../prompts/system/interrupted-thinking.md" with { type: "text" };
235
240
  import ircAutoReplyTemplate from "../prompts/system/irc-autoreply.md" with { type: "text" };
236
241
  import ircIncomingTemplate from "../prompts/system/irc-incoming.md" with { type: "text" };
237
242
  import planModeActivePrompt from "../prompts/system/plan-mode-active.md" with { type: "text" };
@@ -261,6 +266,7 @@ import {
261
266
  shouldDisableReasoning,
262
267
  toReasoningEffort,
263
268
  } from "../thinking";
269
+ import { formatTitleConversationContext, type TitleConversationTurn } from "../tiny/text";
264
270
  import { shutdownTinyTitleClient } from "../tiny/title-client";
265
271
  import { countToolsForAutoDiscovery, resolveEffectiveToolDiscoveryMode } from "../tool-discovery/mode";
266
272
  import {
@@ -288,6 +294,7 @@ import { resolveFileDisplayMode } from "../utils/file-display-mode";
288
294
  import { extractFileMentions, generateFileMentionMessages } from "../utils/file-mentions";
289
295
  import { normalizeModelContextImages } from "../utils/image-loading";
290
296
  import { describeAttachedImagesForTextModel } from "../utils/image-vision-fallback";
297
+ import { generateSessionTitle } from "../utils/title-generator";
291
298
  import { buildNamedToolChoice, isToolChoiceActive } from "../utils/tool-choice";
292
299
  import type { AuthStorage } from "./auth-storage";
293
300
  import type { ClientBridge, ClientBridgePermissionOption, ClientBridgePermissionOutcome } from "./client-bridge";
@@ -303,6 +310,10 @@ import {
303
310
  type BashExecutionMessage,
304
311
  type CustomMessage,
305
312
  convertToLlm,
313
+ demoteInterruptedThinking,
314
+ INTERRUPTED_THINKING_MESSAGE_TYPE,
315
+ type InterruptedThinkingDetails,
316
+ isUserInterruptAbort,
306
317
  type PythonExecutionMessage,
307
318
  readQueueChipText,
308
319
  SILENT_ABORT_MARKER,
@@ -599,20 +610,29 @@ export interface AgentSessionConfig {
599
610
  */
600
611
  providerSessionId?: string;
601
612
  /**
602
- * Hard-isolated read-only tools (read/search/find) for the advisor agent,
603
- * pre-built in `createAgentSession` against a distinct `ToolSession` so the
604
- * advisor's reads never share the primary's snapshot/seen-lines/conflict
605
- * caches. Undefined when the advisor is disabled.
613
+ * Full advisor toolset, pre-built in `createAgentSession` against a distinct,
614
+ * advisor-scoped `ToolSession` (its own `-advisor` session/agent id) so the
615
+ * advisor's tool state stays isolated from the primary. The advisor is a full
616
+ * agent; its config `tools` selects a subset (default read/grep/glob). Undefined
617
+ * when the advisor is disabled.
606
618
  */
607
- advisorReadOnlyTools?: AgentTool[];
619
+ advisorTools?: AgentTool[];
608
620
  /** Preloaded watchdog prompt content for the advisor. */
609
621
  advisorWatchdogPrompt?: string;
622
+ /** Preloaded YAML top-level `instructions` shared baseline, kept separate from
623
+ * `advisorWatchdogPrompt` so `/advisor configure` can swap it live. */
624
+ advisorSharedInstructions?: string;
610
625
  /**
611
626
  * Preloaded project context files (AGENTS.md, etc.) rendered as a system-prompt
612
627
  * block for the advisor — the same standing instructions the primary agent
613
628
  * receives, so the reviewer holds the agent to them.
614
629
  */
615
630
  advisorContextPrompt?: string;
631
+ /**
632
+ * Advisors discovered from `WATCHDOG.yml`. Empty/undefined runs a single
633
+ * legacy advisor on the `advisor` role (byte-for-byte the pre-config path).
634
+ */
635
+ advisorConfigs?: AdvisorConfig[];
616
636
  /**
617
637
  * Strip tool descriptions from provider-bound tool specs on side requests
618
638
  * (handoff). Must match the session-start value used to build the system
@@ -717,6 +737,7 @@ export interface SessionStats {
717
737
  tokens: {
718
738
  input: number;
719
739
  output: number;
740
+ reasoning: number;
720
741
  cacheRead: number;
721
742
  cacheWrite: number;
722
743
  total: number;
@@ -735,6 +756,7 @@ export interface AdvisorStats {
735
756
  tokens: {
736
757
  input: number;
737
758
  output: number;
759
+ reasoning: number;
738
760
  cacheRead: number;
739
761
  cacheWrite: number;
740
762
  total: number;
@@ -745,6 +767,43 @@ export interface AdvisorStats {
745
767
  assistant: number;
746
768
  total: number;
747
769
  };
770
+ /** Per-advisor breakdown; one entry per active advisor (single-advisor sessions have one). */
771
+ advisors: PerAdvisorStat[];
772
+ }
773
+
774
+ /** One advisor's slice of {@link AdvisorStats}, surfaced for the multi-advisor status panel. */
775
+ export interface PerAdvisorStat {
776
+ name: string;
777
+ model: Model;
778
+ contextWindow: number;
779
+ contextTokens: number;
780
+ tokens: AdvisorStats["tokens"];
781
+ cost: number;
782
+ messages: AdvisorStats["messages"];
783
+ }
784
+
785
+ /**
786
+ * One live advisor instance: its own agent/runtime/tools/recorder plus a
787
+ * per-advisor emission guard and identity. The session holds an array of these;
788
+ * primary-scoped state (turn counters, interrupt latches, the shared yield
789
+ * channel) stays on the session.
790
+ */
791
+ interface ActiveAdvisor {
792
+ /** Display name from config ("default" for the legacy no-YAML advisor). */
793
+ name: string;
794
+ /** Slug for the transcript filename/session id; "" → `__advisor.jsonl`. */
795
+ slug: string;
796
+ agent: Agent;
797
+ runtime: AdvisorRuntime;
798
+ adviseTool: AdviseTool;
799
+ emissionGuard: AdvisorEmissionGuard;
800
+ recorder: AdvisorTranscriptRecorder;
801
+ /** Latest recorder close, awaited by dispose() so the final turn lands on disk. */
802
+ recorderClosed: Promise<void>;
803
+ /** Unsubscribe for the advisor agent's event stream feeding the recorder. */
804
+ agentUnsubscribe?: () => void;
805
+ model: Model;
806
+ thinkingLevel: ThinkingLevel;
748
807
  }
749
808
 
750
809
  export interface FreshSessionResult {
@@ -1189,6 +1248,60 @@ type MessageEndPersistenceSlot = {
1189
1248
  release: () => void;
1190
1249
  };
1191
1250
 
1251
+ const REPLAN_TITLE_CONTEXT_TURN_LIMIT = 6;
1252
+
1253
+ type SessionTitleSource = "auto" | "user";
1254
+ type SessionNameTrigger = "replan";
1255
+ type SetSessionNameWithTrigger = (
1256
+ name: string,
1257
+ source?: SessionTitleSource,
1258
+ trigger?: SessionNameTrigger,
1259
+ ) => Promise<boolean>;
1260
+
1261
+ function isRecord(value: unknown): value is Record<string, unknown> {
1262
+ return value !== null && typeof value === "object" && !Array.isArray(value);
1263
+ }
1264
+
1265
+ function textFromContent(content: unknown): string {
1266
+ if (typeof content === "string") return content.trim();
1267
+ if (!Array.isArray(content)) return "";
1268
+ const parts: string[] = [];
1269
+ for (const block of content) {
1270
+ if (!isRecord(block) || block.type !== "text" || typeof block.text !== "string") continue;
1271
+ const text = block.text.trim();
1272
+ if (text) parts.push(text);
1273
+ }
1274
+ return parts.join("\n\n");
1275
+ }
1276
+
1277
+ function thinkingFromContent(content: unknown): string {
1278
+ if (!Array.isArray(content)) return "";
1279
+ const parts: string[] = [];
1280
+ for (const block of content) {
1281
+ if (!isRecord(block) || block.type !== "thinking" || typeof block.thinking !== "string") continue;
1282
+ const thinking = block.thinking.trim();
1283
+ if (thinking) parts.push(thinking);
1284
+ }
1285
+ return parts.join("\n\n");
1286
+ }
1287
+
1288
+ function toolCallOpFromMessage(message: AgentMessage, toolCallId: string): string | undefined {
1289
+ if (message.role !== "assistant" || !Array.isArray(message.content)) return undefined;
1290
+ for (const block of message.content) {
1291
+ if (!isRecord(block) || block.type !== "toolCall" || block.id !== toolCallId) continue;
1292
+ return isRecord(block.arguments) ? getStringProperty(block.arguments, "op") : undefined;
1293
+ }
1294
+ return undefined;
1295
+ }
1296
+
1297
+ function titleConversationTurnFromMessage(message: AgentMessage): TitleConversationTurn | undefined {
1298
+ if (message.role !== "user" && message.role !== "assistant") return undefined;
1299
+ const text = textFromContent(message.content);
1300
+ const thinking = message.role === "assistant" ? thinkingFromContent(message.content) : undefined;
1301
+ if (!text && !thinking) return undefined;
1302
+ return { role: message.role, ...(text ? { text } : {}), ...(thinking ? { thinking } : {}) };
1303
+ }
1304
+
1192
1305
  export class AgentSession {
1193
1306
  readonly agent: Agent;
1194
1307
  readonly sessionManager: SessionManager;
@@ -1229,29 +1342,21 @@ export class AgentSession {
1229
1342
  #advisorAutoResumeSuppressed = false;
1230
1343
  #advisorPrimaryTurnsCompleted = 0;
1231
1344
  #advisorInterruptImmuneTurnStart: number | undefined;
1232
- /** Dedupe + per-update rate-limit + content-free-phrase filter applied to
1233
- * every accepted advisor `advise()` call. Owned by the session because the
1234
- * session is what routes accepted notes back to the primary transcript.
1235
- * Reset on advisor reset (compaction, session switch, `/new`). */
1236
- readonly #advisorEmissionGuard = new AdvisorEmissionGuard();
1237
1345
  #planModeState: PlanModeState | undefined;
1238
1346
  #goalModeState: GoalModeState | undefined;
1239
1347
  #goalRuntime: GoalRuntime;
1240
- #advisorRuntime?: AdvisorRuntime;
1241
1348
  #advisorEnabled = false;
1242
- /** The advisor's own agent, retained so `/dump advisor` can serialize its transcript. Undefined when no advisor is active. */
1243
- #advisorAgent?: Agent;
1244
- #advisorAdviseTool?: AdviseTool;
1245
- #advisorReadOnlyTools?: AgentTool[];
1349
+ #advisorTools?: AgentTool[];
1246
1350
  #advisorWatchdogPrompt?: string;
1351
+ #advisorSharedInstructions?: string;
1247
1352
  #advisorContextPrompt?: string;
1248
1353
  #advisorYieldQueueUnsubscribe?: () => void;
1249
- /** Persists the advisor agent's turns to `<session>/__advisor.jsonl` for stats
1250
- * attribution and Agent Hub observability. Undefined when no advisor is active. */
1251
- #advisorTranscriptRecorder?: AdvisorTranscriptRecorder;
1252
- /** Unsubscribe for the advisor agent's event stream feeding the recorder. */
1253
- #advisorAgentUnsubscribe?: () => void;
1254
- /** Latest advisor-recorder close, awaited by dispose() so the final turn lands on disk. */
1354
+ /** Live advisors. Empty when no advisor is active. */
1355
+ #advisors: ActiveAdvisor[] = [];
1356
+ /** Configured advisor roster from WATCHDOG.yml; undefined/empty → single legacy advisor. */
1357
+ #advisorConfigs?: AdvisorConfig[];
1358
+ /** Aggregate of the most recent stop's recorder closes; awaited by dispose() and
1359
+ * used as the open barrier for the next build so two writers never share a file. */
1255
1360
  #advisorRecorderClosed: Promise<void> = Promise.resolve();
1256
1361
  #goalTurnCounter = 0;
1257
1362
  #planReferenceSent = false;
@@ -1290,6 +1395,7 @@ export class AgentSession {
1290
1395
  */
1291
1396
  #todoReminderAwaitingProgress = false;
1292
1397
  #todoPhases: TodoPhase[] = [];
1398
+ #replanTitleRefreshInFlight: Promise<void> | undefined = undefined;
1293
1399
  #toolChoiceQueue = new ToolChoiceQueue();
1294
1400
 
1295
1401
  // Bash execution state
@@ -1669,9 +1775,11 @@ export class AgentSession {
1669
1775
  // toggle scopes priority to Fireworks alone, without mutating the shared
1670
1776
  // session `serviceTier` that drives `/fast` and OpenAI/Anthropic priority.
1671
1777
  this.agent.serviceTierResolver = model => this.#effectiveServiceTier(model);
1672
- this.#advisorReadOnlyTools = config.advisorReadOnlyTools;
1778
+ this.#advisorTools = config.advisorTools;
1673
1779
  this.#advisorWatchdogPrompt = config.advisorWatchdogPrompt;
1780
+ this.#advisorSharedInstructions = config.advisorSharedInstructions;
1674
1781
  this.#advisorContextPrompt = config.advisorContextPrompt;
1782
+ this.#advisorConfigs = config.advisorConfigs;
1675
1783
  this.#pruneToolDescriptions = config.pruneToolDescriptions === true;
1676
1784
  this.#validateRetryFallbackChains();
1677
1785
  this.#toolRegistry = config.toolRegistry ?? new Map();
@@ -1716,12 +1824,15 @@ export class AgentSession {
1716
1824
  await this.#applyRewind(rewindReport, messages);
1717
1825
  }
1718
1826
  this.#advisorPrimaryTurnsCompleted++;
1719
- if (this.#advisorRuntime && !this.#advisorRuntime.disposed) {
1720
- this.#advisorRuntime.onTurnEnd(messages);
1827
+ if (this.#advisors.length > 0) {
1828
+ for (const a of this.#advisors) {
1829
+ if (!a.runtime.disposed) a.runtime.onTurnEnd(messages);
1830
+ }
1721
1831
  const syncBacklog = this.settings.get("advisor.syncBacklog");
1722
1832
  if (syncBacklog !== "off") {
1723
1833
  const threshold = parseInt(syncBacklog, 10);
1724
- await this.#advisorRuntime.waitForCatchup(30000, threshold, signal);
1834
+ // Parallel so the 30s catch-up budget is shared across advisors, not summed.
1835
+ await Promise.all(this.#advisors.map(a => a.runtime.waitForCatchup(30000, threshold, signal)));
1725
1836
  }
1726
1837
  }
1727
1838
  await this.#maintainContextMidRun(messages, signal, context);
@@ -1891,12 +2002,14 @@ export class AgentSession {
1891
2002
  // Mute the recorder across the re-prime: AdvisorRuntime.reset() aborts the advisor
1892
2003
  // loop, and that abort can emit an `aborted` message_end we must not attribute to
1893
2004
  // either session's transcript. Detach, reset, then re-attach the live agent's feed.
1894
- this.#advisorAgentUnsubscribe?.();
1895
- this.#advisorAgentUnsubscribe = undefined;
1896
- this.#advisorRuntime?.reset();
1897
- this.#advisorAdviseTool?.resetDeliveredNotes();
1898
- this.#advisorEmissionGuard.reset();
1899
- this.#attachAdvisorRecorderFeed();
2005
+ for (const a of this.#advisors) {
2006
+ a.agentUnsubscribe?.();
2007
+ a.agentUnsubscribe = undefined;
2008
+ a.runtime.reset();
2009
+ a.adviseTool.resetDeliveredNotes();
2010
+ a.emissionGuard.reset();
2011
+ this.#attachAdvisorRecorderFeed(a);
2012
+ }
1900
2013
  this.#advisorPrimaryTurnsCompleted = 0;
1901
2014
  this.#advisorInterruptImmuneTurnStart = undefined;
1902
2015
  this.#advisorAutoResumeSuppressed = false;
@@ -1909,261 +2022,298 @@ export class AgentSession {
1909
2022
 
1910
2023
  #buildAdvisorRuntime(seedToCurrent = false): boolean {
1911
2024
  if (this.#isDisposed) return false;
1912
- if (this.#advisorRuntime) return true;
2025
+ if (this.#advisors.length > 0) return true;
1913
2026
  if (!this.#advisorEnabled) return false;
1914
2027
  if (this.#agentKind !== "main" && !this.settings.get("advisor.subagents")) return false;
1915
2028
 
1916
- const advisorSel = resolveAdvisorRoleSelection(
1917
- this.settings,
1918
- this.#modelRegistry.getAvailable(),
1919
- this.#modelRegistry,
1920
- );
1921
- if (!advisorSel) {
1922
- logger.debug("advisor enabled but no model assigned to the 'advisor' role; advisor inactive");
1923
- return false;
1924
- }
1925
-
1926
- // Concern and blocker interrupt the running agent through the steering
1927
- // channel (aborting in-flight tools at the next steering boundary); when the
1928
- // loop has already yielded, triggerTurn resumes it so the advice is acted on
1929
- // immediately rather than waiting for the next user prompt. After a deliberate
1930
- // user interrupt the auto-resume is suppressed — but only while the agent is
1931
- // idle or still tearing the interrupted turn down: a concern is then recorded
1932
- // as a visible card and re-enters context when the user resumes. Once a turn
1933
- // is streaming again (a resume the user already drove) it is steered in live,
1934
- // since steering an active run auto-resumes nothing; parking it there would
1935
- // strand the advice and dump the backlog as one burst at the next prompt. A
1936
- // plain nit always rides the non-interrupting YieldQueue aside.
1937
- // Apply the per-session emission policy (one-advise-per-update gate,
1938
- // exact-text dedupe, content-free phrase filter) before any routing.
1939
- // Suppression here means the advisor model called `advise()` but the call
1940
- // is dropped silently — the model still sees `Recorded.` from the tool, so
1941
- // telling it "suppressed" doesn't tempt it into rephrasing the same useless
1942
- // note to bypass the dedupe.
1943
- const enqueueAdvice = (note: string, severity?: AdvisorSeverity) => {
1944
- if (!this.#advisorEmissionGuard.accept(note)) {
1945
- logger.debug("advisor advice suppressed by emission guard", { severity });
1946
- return;
1947
- }
1948
- const interrupting = isInterruptingSeverity(severity);
1949
- const channel = resolveAdvisorDeliveryChannel({
1950
- severity,
1951
- autoResumeSuppressed: this.#advisorAutoResumeSuppressed,
1952
- // Key on the live agent-core loop, not session `isStreaming` (which also
1953
- // counts `#promptInFlightCount` during post-turn unwind). Only a running
1954
- // loop will consume a steer at its next boundary; steering into the unwind
1955
- // window would strand the card and let #drainStrandedQueuedMessages
1956
- // auto-resume it despite the user's interrupt.
1957
- streaming: this.agent.state.isStreaming,
1958
- aborting: this.#abortInProgress,
1959
- interruptImmuneTurnActive: interrupting && this.#isAdvisorInterruptImmuneTurnActive(),
1960
- });
1961
- if (channel === "aside") {
1962
- this.yieldQueue.enqueue("advisor", { note, severity });
1963
- return;
1964
- }
1965
- const notes: AdvisorNote[] = [{ note, severity }];
1966
- const content = formatAdvisorBatchContent(notes);
1967
- const details = { notes } satisfies AdvisorMessageDetails;
1968
- if (channel === "preserve") {
1969
- this.#preserveAdvisorCard({
1970
- role: "custom",
1971
- customType: "advisor",
1972
- content,
1973
- display: true,
1974
- attribution: "agent",
1975
- details,
1976
- timestamp: Date.now(),
1977
- });
1978
- return;
1979
- }
1980
- this.#recordAdvisorInterruptDelivered();
1981
- void this.sendCustomMessage(
1982
- { customType: "advisor", content, display: true, attribution: "agent", details },
1983
- { deliverAs: "steer", triggerTurn: true },
1984
- ).catch(err => logger.debug("advisor delivery failed", { err: String(err) }));
1985
- };
1986
-
1987
- const adviseTool = new AdviseTool(enqueueAdvice);
1988
- this.#advisorAdviseTool = adviseTool;
1989
- const advisorReadOnlyTools = this.#advisorReadOnlyTools ?? [];
1990
-
1991
- const appendOnlyContext = new AppendOnlyContextManager();
1992
- const advisorThinkingLevel = advisorSel.thinkingLevel ?? ThinkingLevel.Medium;
1993
- const systemPrompt = [advisorSystemPrompt];
1994
- if (this.#advisorContextPrompt) {
1995
- systemPrompt.push(this.#advisorContextPrompt);
1996
- }
1997
- if (this.#advisorWatchdogPrompt) {
1998
- systemPrompt.push(this.#advisorWatchdogPrompt);
1999
- }
2000
- const advisorSessionId = this.sessionId ? `${this.sessionId}-advisor` : undefined;
2029
+ // The lone implicit "default" advisor (slug "") reproduces the legacy
2030
+ // single-advisor path: the `advisor` role model + `__advisor.jsonl`.
2031
+ const legacy = !this.#advisorConfigs?.length;
2032
+ const roster: AdvisorConfig[] = legacy ? [{ name: "default" }] : this.#advisorConfigs!;
2001
2033
 
2002
2034
  // Advisor service tier (`serviceTierAdvisor`): "none" (default) runs the
2003
2035
  // advisor on standard processing; "inherit" tracks the session's live tier
2004
2036
  // per request (like the main agent, including /fast toggles) via a resolver;
2005
- // a concrete value pins the advisor to that tier regardless of the session.
2037
+ // a concrete value pins the advisor to that tier. One value for all advisors.
2006
2038
  const advisorTierSetting = this.settings.get("serviceTierAdvisor");
2007
2039
  const advisorServiceTier =
2008
2040
  advisorTierSetting === "inherit" ? undefined : resolveServiceTierSetting(advisorTierSetting, undefined);
2009
2041
  const advisorServiceTierResolver =
2010
2042
  advisorTierSetting === "inherit" ? (model: Model) => this.#effectiveServiceTier(model) : undefined;
2011
2043
 
2012
- // Thread the primary's telemetry into the advisor loop so the advisor
2013
- // model's GenAI spans + usage/cost hooks fire like every other model call,
2014
- // stamped with the advisor's own identity. `conversationId` is cleared so
2015
- // the advisor loop falls back to its own `-advisor` session id for
2016
- // `gen_ai.conversation.id` instead of inheriting the primary's
2017
- // conversation; undefined telemetry stays undefined (zero-overhead no-op).
2018
- const advisorTelemetry = this.agent.telemetry
2019
- ? {
2020
- ...this.agent.telemetry,
2021
- agent: {
2022
- id: advisorSessionId,
2023
- name: MODEL_ROLES.advisor.name,
2024
- description: formatModelString(advisorSel.model),
2025
- },
2026
- conversationId: undefined,
2044
+ const usedSlugs = new Set<string>();
2045
+ for (const config of roster) {
2046
+ let slug = legacy ? "" : slugifyAdvisorName(config.name);
2047
+ if (slug) {
2048
+ let candidate = slug;
2049
+ let n = 2;
2050
+ while (usedSlugs.has(candidate)) candidate = `${slug}-${n++}`;
2051
+ slug = candidate;
2052
+ usedSlugs.add(slug);
2053
+ }
2054
+
2055
+ // Resolve the advisor's model: an explicit `model` override wins; else the
2056
+ // `advisor` role chain. A model that fails to resolve skips just this advisor.
2057
+ let model: Model | undefined;
2058
+ let thinkingLevel: ThinkingLevel | undefined;
2059
+ if (config.model) {
2060
+ const resolved = resolveModelOverride([config.model], this.#modelRegistry, this.settings);
2061
+ model = resolved.model;
2062
+ thinkingLevel = resolved.thinkingLevel;
2063
+ if (!model) {
2064
+ this.emitNotice("warning", `Advisor "${config.name}": no model matched "${config.model}"`, "advisor");
2065
+ continue;
2027
2066
  }
2028
- : undefined;
2029
- // Mirror the provider-shaping options the SDK installs on the main agent
2030
- // so the advisor's requests cache, route, and obfuscate identically:
2031
- //
2032
- // - `streamFn`/`advisorStreamFn` carries the session's OpenRouter sticky
2033
- // variant, antigravity endpoint mode, in-flight cap, and loop guard.
2034
- // - `onPayload`/`onResponse`/`onSseEvent` keep extension hooks plus the
2035
- // per-session `RawSseDebugBuffer` recording advisor traffic too.
2036
- // - `providerSessionState` shares Codex websockets / Anthropic fast-mode
2037
- // fallback state with the main agent so both turns reuse the same
2038
- // transport caches.
2039
- // - `promptCacheKey` pins OpenAI Responses (and any provider that reads
2040
- // `prompt_cache_key`) to the advisor session id so consecutive advisor
2041
- // turns land on the same cache shard.
2042
- // - `transformProviderContext` applies snapcompact, secret obfuscation,
2043
- // and image clamping to advisor requests like the main turn.
2044
- //
2045
- // Without this parity, OpenRouter advisor calls bypassed the variant
2046
- // suffix and prompt-cache key and produced inconsistent cache hits
2047
- // (see can1357/oh-my-pi#3639).
2048
- const advisorAgent = new Agent({
2049
- initialState: {
2050
- systemPrompt,
2051
- model: advisorSel.model,
2052
- thinkingLevel: toReasoningEffort(advisorThinkingLevel),
2053
- tools: [adviseTool, ...advisorReadOnlyTools],
2054
- },
2055
- appendOnlyContext,
2056
- sessionId: advisorSessionId,
2057
- promptCacheKey: advisorSessionId,
2058
- providerSessionState: this.#providerSessionState,
2059
- getApiKey: requestModel => this.#modelRegistry.resolver(requestModel, advisorSessionId),
2060
- streamFn: this.#advisorStreamFn,
2061
- onPayload: this.#onPayload,
2062
- onResponse: this.#onResponse,
2063
- onSseEvent: this.#onSseEvent,
2064
- transformProviderContext: this.#transformProviderContext,
2065
- intentTracing: false,
2066
- telemetry: advisorTelemetry,
2067
- serviceTier: advisorServiceTier,
2068
- serviceTierResolver: advisorServiceTierResolver,
2069
- });
2070
- advisorAgent.setDisableReasoning(shouldDisableReasoning(advisorThinkingLevel));
2071
-
2072
- const advisorAgentFacade: AdvisorAgent = {
2073
- prompt: input => advisorAgent.prompt(input),
2074
- abort: reason => advisorAgent.abort(reason),
2075
- reset: () => {
2076
- advisorAgent.reset();
2077
- appendOnlyContext.log.clear();
2078
- },
2079
- rollbackTo: count => {
2080
- // Drop the failed user batch + synthetic assistant-error turn
2081
- // `Agent.#runLoop` appended for a turn ending in `stopReason: "error"`.
2082
- // The append-only context auto-resyncs on the next prompt's
2083
- // `syncMessages` shrink path, but reset the sync cursor so the log
2084
- // can't carry the dropped tail forward if no further turn ever runs.
2085
- const messages = advisorAgent.state.messages;
2086
- if (count < messages.length) {
2087
- messages.length = count;
2067
+ } else {
2068
+ const sel = resolveAdvisorRoleSelection(
2069
+ this.settings,
2070
+ this.#modelRegistry.getAvailable(),
2071
+ this.#modelRegistry,
2072
+ );
2073
+ if (!sel) {
2074
+ logger.debug("advisor enabled but no model assigned to the 'advisor' role; advisor inactive", {
2075
+ advisor: config.name,
2076
+ });
2077
+ continue;
2088
2078
  }
2089
- appendOnlyContext.resetSyncCursor();
2090
- advisorAgent.state.error = undefined;
2091
- },
2092
- state: advisorAgent.state,
2093
- };
2079
+ model = sel.model;
2080
+ thinkingLevel = sel.thinkingLevel;
2081
+ }
2082
+ const advisorModel = model;
2083
+ const advisorName = config.name;
2084
+ const advisorThinkingLevel = thinkingLevel ?? ThinkingLevel.Medium;
2085
+
2086
+ const emissionGuard = new AdvisorEmissionGuard();
2087
+ const adviseTool = new AdviseTool((note, severity) => this.#routeAdvice(advisorRef, note, severity));
2088
+
2089
+ // `#advisorWatchdogPrompt` already carries WATCHDOG.md + YAML shared
2090
+ // instructions; `config.instructions` adds this advisor's specialization.
2091
+ const systemPrompt = [advisorSystemPrompt];
2092
+ if (this.#advisorContextPrompt) systemPrompt.push(this.#advisorContextPrompt);
2093
+ if (this.#advisorWatchdogPrompt) systemPrompt.push(this.#advisorWatchdogPrompt);
2094
+ if (this.#advisorSharedInstructions) systemPrompt.push(this.#advisorSharedInstructions);
2095
+ if (config.instructions?.trim()) systemPrompt.push(config.instructions.trim());
2096
+
2097
+ const names = config.tools?.length ? new Set(config.tools) : ADVISOR_DEFAULT_TOOL_NAMES;
2098
+ const tools = (this.#advisorTools ?? []).filter(t => names.has(t.name));
2099
+
2100
+ const advisorSessionId = this.#advisorSessionId(slug);
2101
+ const appendOnlyContext = new AppendOnlyContextManager();
2102
+
2103
+ // Thread the primary's telemetry into the advisor loop so the advisor
2104
+ // model's GenAI spans + usage/cost hooks fire stamped with the advisor's
2105
+ // own identity. `conversationId` is cleared so the advisor loop falls back
2106
+ // to its own session id; undefined telemetry stays undefined.
2107
+ const advisorTelemetry = this.agent.telemetry
2108
+ ? {
2109
+ ...this.agent.telemetry,
2110
+ agent: {
2111
+ id: advisorSessionId,
2112
+ name: slug ? `${MODEL_ROLES.advisor.name}: ${advisorName}` : MODEL_ROLES.advisor.name,
2113
+ description: formatModelString(advisorModel),
2114
+ },
2115
+ conversationId: undefined,
2116
+ }
2117
+ : undefined;
2118
+ // Mirror the SDK's provider-shaping options (streamFn/onPayload/...,
2119
+ // providerSessionState, promptCacheKey, transformProviderContext) so each
2120
+ // advisor's requests cache, route, and obfuscate like the main turn.
2121
+ const advisorAgent = new Agent({
2122
+ initialState: {
2123
+ systemPrompt,
2124
+ model: advisorModel,
2125
+ thinkingLevel: toReasoningEffort(advisorThinkingLevel),
2126
+ tools: [adviseTool, ...tools],
2127
+ },
2128
+ appendOnlyContext,
2129
+ sessionId: advisorSessionId,
2130
+ promptCacheKey: advisorSessionId,
2131
+ providerSessionState: this.#providerSessionState,
2132
+ getApiKey: requestModel => this.#modelRegistry.resolver(requestModel, advisorSessionId),
2133
+ streamFn: this.#advisorStreamFn,
2134
+ onPayload: this.#onPayload,
2135
+ onResponse: this.#onResponse,
2136
+ onSseEvent: this.#onSseEvent,
2137
+ transformProviderContext: this.#transformProviderContext,
2138
+ intentTracing: false,
2139
+ telemetry: advisorTelemetry,
2140
+ serviceTier: advisorServiceTier,
2141
+ serviceTierResolver: advisorServiceTierResolver,
2142
+ });
2143
+ advisorAgent.setDisableReasoning(shouldDisableReasoning(advisorThinkingLevel));
2144
+
2145
+ const advisorAgentFacade: AdvisorAgent = {
2146
+ prompt: input => advisorAgent.prompt(input),
2147
+ abort: reason => advisorAgent.abort(reason),
2148
+ reset: () => {
2149
+ advisorAgent.reset();
2150
+ appendOnlyContext.log.clear();
2151
+ },
2152
+ rollbackTo: count => {
2153
+ // Drop the failed user batch + synthetic assistant-error turn
2154
+ // `Agent.#runLoop` appended for a turn ending in `stopReason: "error"`.
2155
+ const messages = advisorAgent.state.messages;
2156
+ if (count < messages.length) {
2157
+ messages.length = count;
2158
+ }
2159
+ appendOnlyContext.resetSyncCursor();
2160
+ advisorAgent.state.error = undefined;
2161
+ },
2162
+ state: advisorAgent.state,
2163
+ };
2094
2164
 
2095
- this.#advisorAgent = advisorAgent;
2096
- // Persist the advisor's turns to `<session>/__advisor.jsonl` (resolved lazily
2097
- // so it follows session switches) so its model usage is attributed in stats
2098
- // and its transcript shows in the Agent Hub — without registering it as a peer.
2099
- const recorder = new AdvisorTranscriptRecorder(
2100
- () => this.sessionManager.getSessionFile(),
2101
- () => this.sessionManager.getCwd(),
2102
- // On the advisor on→off→on toggle, wait for the prior recorder's close so
2103
- // two SessionManagers never hold the same __advisor.jsonl at once.
2104
- this.#advisorRecorderClosed,
2105
- );
2106
- this.#advisorTranscriptRecorder = recorder;
2107
- this.#attachAdvisorRecorderFeed();
2108
- this.#advisorRuntime = new AdvisorRuntime(advisorAgentFacade, {
2109
- snapshotMessages: () => this.agent.state.messages,
2110
- enqueueAdvice,
2111
- maintainContext: incomingTokens => this.#maintainAdvisorContext(incomingTokens),
2112
- obfuscator: this.#obfuscator,
2113
- beginAdvisorUpdate: () => this.#advisorEmissionGuard.beginUpdate(),
2114
- notifyFailure: error => {
2115
- const message = error instanceof Error ? error.message : String(error);
2116
- this.emitNotice(
2117
- "warning",
2118
- `Advisor unavailable for ${formatModelString(advisorSel.model)}: ${message}`,
2119
- "advisor",
2120
- );
2121
- },
2122
- });
2123
- if (seedToCurrent) {
2124
- this.#advisorRuntime.seedTo(this.agent.state.messages.length);
2125
- }
2126
-
2127
- // Batch non-blocking advisor notes into one injected custom message.
2128
- this.#advisorYieldQueueUnsubscribe = this.yieldQueue.register<AdvisorNote>("advisor", {
2129
- build: entries =>
2130
- entries.length === 0
2131
- ? null
2132
- : ({
2133
- role: "custom",
2134
- customType: "advisor",
2135
- display: true,
2136
- attribution: "agent",
2137
- timestamp: Date.now(),
2138
- content: formatAdvisorBatchContent(entries),
2139
- details: { notes: entries } satisfies AdvisorMessageDetails,
2140
- } satisfies CustomMessage),
2141
- skipIdleFlush: true,
2165
+ // Persist this advisor's turns to `<session>/__advisor[.<slug>].jsonl`
2166
+ // (resolved lazily so it follows session switches) for stats attribution
2167
+ // and Agent Hub observability, without registering it as a peer.
2168
+ const recorder = new AdvisorTranscriptRecorder(
2169
+ () => this.sessionManager.getSessionFile(),
2170
+ () => this.sessionManager.getCwd(),
2171
+ advisorTranscriptFilename(slug),
2172
+ // On the advisor on→off→on toggle, wait for the prior recorders' closes
2173
+ // so two SessionManagers never hold the same file at once.
2174
+ this.#advisorRecorderClosed,
2175
+ );
2176
+ const runtime = new AdvisorRuntime(advisorAgentFacade, {
2177
+ snapshotMessages: () => this.agent.state.messages,
2178
+ enqueueAdvice: (note, severity) => this.#routeAdvice(advisorRef, note, severity),
2179
+ maintainContext: incomingTokens => this.#maintainAdvisorContext(advisorRef, incomingTokens),
2180
+ obfuscator: this.#obfuscator,
2181
+ beginAdvisorUpdate: () => advisorRef.emissionGuard.beginUpdate(),
2182
+ notifyFailure: error => {
2183
+ const message = error instanceof Error ? error.message : String(error);
2184
+ this.emitNotice(
2185
+ "warning",
2186
+ `Advisor${slug ? ` "${advisorName}"` : ""} unavailable for ${formatModelString(advisorModel)}: ${message}`,
2187
+ "advisor",
2188
+ );
2189
+ },
2190
+ });
2191
+
2192
+ const advisorRef: ActiveAdvisor = {
2193
+ name: advisorName,
2194
+ slug,
2195
+ agent: advisorAgent,
2196
+ runtime,
2197
+ adviseTool,
2198
+ emissionGuard,
2199
+ recorder,
2200
+ recorderClosed: Promise.resolve(),
2201
+ model: advisorModel,
2202
+ thinkingLevel: advisorThinkingLevel,
2203
+ };
2204
+ this.#attachAdvisorRecorderFeed(advisorRef);
2205
+ if (seedToCurrent) runtime.seedTo(this.agent.state.messages.length);
2206
+ this.#advisors.push(advisorRef);
2207
+ }
2208
+
2209
+ // One shared non-blocking aside channel for all advisors; the build callback
2210
+ // aggregates every advisor's queued nits into one card (each entry already
2211
+ // carries its own `advisor` name).
2212
+ if (this.#advisors.length > 0 && !this.#advisorYieldQueueUnsubscribe) {
2213
+ this.#advisorYieldQueueUnsubscribe = this.yieldQueue.register<AdvisorNote>("advisor", {
2214
+ build: entries =>
2215
+ entries.length === 0
2216
+ ? null
2217
+ : ({
2218
+ role: "custom",
2219
+ customType: "advisor",
2220
+ display: true,
2221
+ attribution: "agent",
2222
+ timestamp: Date.now(),
2223
+ content: formatAdvisorBatchContent(entries),
2224
+ details: { notes: entries } satisfies AdvisorMessageDetails,
2225
+ } satisfies CustomMessage),
2226
+ skipIdleFlush: true,
2227
+ });
2228
+ }
2229
+
2230
+ return this.#advisors.length > 0;
2231
+ }
2232
+
2233
+ /** Provider/session id for an advisor's loop. The slug suffix MUST match the
2234
+ * advisor's transcript filename so stats/telemetry attribute the same advisor. */
2235
+ #advisorSessionId(slug: string): string | undefined {
2236
+ if (!this.sessionId) return undefined;
2237
+ return slug ? `${this.sessionId}-advisor-${slug}` : `${this.sessionId}-advisor`;
2238
+ }
2239
+
2240
+ /**
2241
+ * Route one accepted advice note from `advisor` to the primary. Concern and
2242
+ * blocker interrupt the running agent through the steering channel; once the
2243
+ * loop has yielded, `triggerTurn` resumes it. After a deliberate user interrupt
2244
+ * auto-resume is suppressed while idle/unwinding (the note becomes a preserved
2245
+ * card re-entering on resume); a live-streaming turn is steered in directly. A
2246
+ * plain nit always rides the non-interrupting YieldQueue aside. Suppression by
2247
+ * the per-advisor emission guard drops the note silently — the model still saw
2248
+ * `Recorded.`, so it isn't tempted to rephrase the same note past the dedupe.
2249
+ */
2250
+ #routeAdvice(advisor: ActiveAdvisor, note: string, severity?: AdvisorSeverity): void {
2251
+ if (!advisor.emissionGuard.accept(note)) {
2252
+ logger.debug("advisor advice suppressed by emission guard", { severity, advisor: advisor.name });
2253
+ return;
2254
+ }
2255
+ // The implicit single ("default") advisor stamps no source name, so its
2256
+ // agent-facing `<advisory>` bytes stay identical to the pre-multi-advisor path.
2257
+ const source = advisor.slug ? advisor.name : undefined;
2258
+ const interrupting = isInterruptingSeverity(severity);
2259
+ const channel = resolveAdvisorDeliveryChannel({
2260
+ severity,
2261
+ autoResumeSuppressed: this.#advisorAutoResumeSuppressed,
2262
+ // Key on the live agent-core loop, not session `isStreaming` (which also
2263
+ // counts `#promptInFlightCount` during post-turn unwind). Only a running
2264
+ // loop consumes a steer at its next boundary.
2265
+ streaming: this.agent.state.isStreaming,
2266
+ aborting: this.#abortInProgress,
2267
+ interruptImmuneTurnActive: interrupting && this.#isAdvisorInterruptImmuneTurnActive(),
2142
2268
  });
2269
+ if (channel === "aside") {
2270
+ this.yieldQueue.enqueue("advisor", { note, severity, advisor: source });
2271
+ return;
2272
+ }
2273
+ const notes: AdvisorNote[] = [{ note, severity, advisor: source }];
2274
+ const content = formatAdvisorBatchContent(notes);
2275
+ const details = { notes } satisfies AdvisorMessageDetails;
2276
+ if (channel === "preserve") {
2277
+ this.#preserveAdvisorCard({
2278
+ role: "custom",
2279
+ customType: "advisor",
2280
+ content,
2281
+ display: true,
2282
+ attribution: "agent",
2283
+ details,
2284
+ timestamp: Date.now(),
2285
+ });
2286
+ return;
2287
+ }
2288
+ this.#recordAdvisorInterruptDelivered();
2289
+ void this.sendCustomMessage(
2290
+ { customType: "advisor", content, display: true, attribution: "agent", details },
2291
+ { deliverAs: "steer", triggerTurn: true },
2292
+ ).catch(err => logger.debug("advisor delivery failed", { err: String(err) }));
2293
+ }
2143
2294
 
2144
- return true;
2295
+ /** Re-prime every advisor's transcript view (compaction/shake/rewind) without the
2296
+ * session-level latch reset {@link #resetAdvisorSessionState} performs. */
2297
+ #resetAllAdvisorRuntimes(): void {
2298
+ for (const a of this.#advisors) a.runtime.reset();
2145
2299
  }
2146
2300
 
2147
2301
  #stopAdvisorRuntime(): void {
2148
- // Detach the recorder feed BEFORE aborting the advisor agent: dispose() aborts
2302
+ // Detach each recorder feed BEFORE aborting its advisor agent: dispose() aborts
2149
2303
  // the loop, and an abort emits a final `message_end` we must not enqueue against
2150
2304
  // a closing recorder (it would reopen and resurrect an already-released file).
2151
- this.#advisorAgentUnsubscribe?.();
2152
- this.#advisorAgentUnsubscribe = undefined;
2153
- if (this.#advisorRuntime) {
2154
- this.#advisorRuntime.dispose();
2155
- this.#advisorRuntime = undefined;
2156
- }
2157
- if (this.#advisorTranscriptRecorder) {
2158
- // Capture the close so dispose()/`/drop` can await the queued open+append+close —
2305
+ const closes: Promise<void>[] = [];
2306
+ for (const a of this.#advisors) {
2307
+ a.agentUnsubscribe?.();
2308
+ a.agentUnsubscribe = undefined;
2309
+ a.runtime.dispose();
2310
+ // Capture each close so dispose()/`/drop` can await the queued open+append+close —
2159
2311
  // the last advisor turn would otherwise be lost on a fast process exit.
2160
- this.#advisorRecorderClosed = this.#advisorTranscriptRecorder.close();
2161
- this.#advisorTranscriptRecorder = undefined;
2162
- }
2163
- if (this.#advisorAgent) {
2164
- this.#advisorAgent = undefined;
2312
+ a.recorderClosed = a.recorder.close();
2313
+ closes.push(a.recorderClosed);
2165
2314
  }
2166
- this.#advisorAdviseTool = undefined;
2315
+ this.#advisorRecorderClosed = Promise.all(closes).then(() => {});
2316
+ this.#advisors = [];
2167
2317
  this.#advisorYieldQueueUnsubscribe?.();
2168
2318
  this.#advisorYieldQueueUnsubscribe = undefined;
2169
2319
  }
@@ -2171,16 +2321,13 @@ export class AgentSession {
2171
2321
  /** Subscribe the advisor agent's finalized messages into the transcript recorder.
2172
2322
  * Idempotent-by-replacement: callers detach the prior feed first. Kept separate
2173
2323
  * so the re-prime path can mute the feed across an abort-driven reset. */
2174
- #attachAdvisorRecorderFeed(): void {
2175
- const agent = this.#advisorAgent;
2176
- const recorder = this.#advisorTranscriptRecorder;
2177
- if (!agent || !recorder) return;
2178
- this.#advisorAgentUnsubscribe = agent.subscribe(event => {
2179
- if (event.type === "message_end") recorder.record(event.message);
2324
+ #attachAdvisorRecorderFeed(advisor: ActiveAdvisor): void {
2325
+ advisor.agentUnsubscribe = advisor.agent.subscribe(event => {
2326
+ if (event.type === "message_end") advisor.recorder.record(event.message);
2180
2327
  });
2181
2328
  }
2182
2329
 
2183
- async #promoteAdvisorContextModel(currentModel: Model): Promise<boolean> {
2330
+ async #promoteAdvisorContextModel(advisor: ActiveAdvisor, currentModel: Model): Promise<boolean> {
2184
2331
  const promotionSettings = this.settings.getGroup("contextPromotion");
2185
2332
  if (!promotionSettings.enabled) return false;
2186
2333
  const contextWindow = currentModel.contextWindow ?? 0;
@@ -2188,25 +2335,23 @@ export class AgentSession {
2188
2335
  const targetModel = await this.#resolveContextPromotionTarget(currentModel, contextWindow);
2189
2336
  if (!targetModel) return false;
2190
2337
 
2191
- const advisorSel = resolveAdvisorRoleSelection(
2192
- this.settings,
2193
- this.#modelRegistry.getAvailable(),
2194
- this.#modelRegistry,
2195
- );
2196
- const advisorThinkingLevel = advisorSel?.thinkingLevel ?? ThinkingLevel.Medium;
2197
-
2338
+ // Preserve this advisor's own thinking level (a configured `model:...:high`
2339
+ // keeps its suffix across a promotion); only the model changes.
2340
+ const advisorThinkingLevel = advisor.thinkingLevel;
2198
2341
  try {
2199
- this.#advisorAgent?.setModel(targetModel);
2200
- this.#advisorAgent?.setThinkingLevel(toReasoningEffort(advisorThinkingLevel));
2201
- this.#advisorAgent?.setDisableReasoning(shouldDisableReasoning(advisorThinkingLevel));
2202
- this.#advisorAgent?.appendOnlyContext?.invalidateForModelChange();
2342
+ advisor.agent.setModel(targetModel);
2343
+ advisor.agent.setThinkingLevel(toReasoningEffort(advisorThinkingLevel));
2344
+ advisor.agent.setDisableReasoning(shouldDisableReasoning(advisorThinkingLevel));
2345
+ advisor.agent.appendOnlyContext?.invalidateForModelChange();
2203
2346
  logger.debug("Advisor context promotion switched model on overflow", {
2347
+ advisor: advisor.name,
2204
2348
  from: `${currentModel.provider}/${currentModel.id}`,
2205
2349
  to: `${targetModel.provider}/${targetModel.id}`,
2206
2350
  });
2207
2351
  return true;
2208
2352
  } catch (error) {
2209
2353
  logger.warn("Advisor context promotion failed", {
2354
+ advisor: advisor.name,
2210
2355
  from: `${currentModel.provider}/${currentModel.id}`,
2211
2356
  to: `${targetModel.provider}/${targetModel.id}`,
2212
2357
  error: String(error),
@@ -2215,19 +2360,18 @@ export class AgentSession {
2215
2360
  }
2216
2361
  }
2217
2362
 
2218
- async #maintainAdvisorContext(incomingTokens: number): Promise<boolean> {
2219
- const advisor = this.#advisorAgent;
2220
- if (!advisor) return false;
2363
+ async #maintainAdvisorContext(advisor: ActiveAdvisor, incomingTokens: number): Promise<boolean> {
2364
+ const agent = advisor.agent;
2221
2365
 
2222
2366
  const compactionSettings = this.settings.getGroup("compaction");
2223
2367
  if (compactionSettings.strategy === "off") return false;
2224
2368
  if (!compactionSettings.enabled) return false;
2225
2369
 
2226
- const advisorModel = advisor.state.model;
2370
+ const advisorModel = agent.state.model;
2227
2371
  const contextWindow = advisorModel.contextWindow ?? 0;
2228
2372
  if (contextWindow <= 0) return false;
2229
2373
 
2230
- const messages = advisor.state.messages;
2374
+ const messages = agent.state.messages;
2231
2375
  let contextTokens = incomingTokens;
2232
2376
  for (const message of messages) {
2233
2377
  contextTokens += estimateTokens(message);
@@ -2238,9 +2382,9 @@ export class AgentSession {
2238
2382
  }
2239
2383
 
2240
2384
  // 1. Try promotion first
2241
- if (await this.#promoteAdvisorContextModel(advisorModel)) {
2385
+ if (await this.#promoteAdvisorContextModel(advisor, advisorModel)) {
2242
2386
  // Promotion succeeded, check if new model has enough space
2243
- const newModel = advisor.state.model;
2387
+ const newModel = agent.state.model;
2244
2388
  const newWindow = newModel.contextWindow ?? 0;
2245
2389
  if (newWindow > 0) {
2246
2390
  const stillNeedsCompaction = shouldCompact(contextTokens, newWindow, compactionSettings);
@@ -2262,7 +2406,9 @@ export class AgentSession {
2262
2406
  timestamp,
2263
2407
  summary: message.summary,
2264
2408
  shortSummary: message.shortSummary,
2265
- firstKeptEntryId: (message as any).firstKeptEntryId || `msg-${i + 1}`,
2409
+ firstKeptEntryId:
2410
+ (message as CompactionSummaryMessage & { firstKeptEntryId?: string }).firstKeptEntryId ||
2411
+ `msg-${i + 1}`,
2266
2412
  tokensBefore: message.tokensBefore,
2267
2413
  } satisfies CompactionEntry;
2268
2414
  }
@@ -2276,33 +2422,37 @@ export class AgentSession {
2276
2422
  } satisfies SessionMessageEntry;
2277
2423
  });
2278
2424
 
2279
- const preparation = prepareCompaction(pathEntries, compactionSettings);
2425
+ const availableModels = this.#modelRegistry.getAvailable();
2426
+ const candidates = this.#resolveCompactionModelCandidates(advisorModel, availableModels);
2427
+ if (candidates.length === 0) {
2428
+ // No compaction candidates, fallback to re-prime
2429
+ return true;
2430
+ }
2431
+ const advisorSessionId = this.#advisorSessionId(advisor.slug);
2432
+ const preparation = prepareCompaction(
2433
+ pathEntries,
2434
+ compactionSettings,
2435
+ await this.#runnableCompactionCandidates(candidates, advisorSessionId),
2436
+ );
2280
2437
  if (!preparation) {
2281
2438
  // Cannot prepare compaction, fallback to re-prime
2282
2439
  return true;
2283
2440
  }
2284
2441
 
2285
- const advisorCompactionThinkingLevel: ThinkingLevel | undefined = advisor.state.disableReasoning
2442
+ const advisorCompactionThinkingLevel: ThinkingLevel | undefined = agent.state.disableReasoning
2286
2443
  ? ThinkingLevel.Off
2287
- : advisor.state.thinkingLevel;
2444
+ : agent.state.thinkingLevel;
2288
2445
 
2289
2446
  // Advisor state is in-memory-only, so snapcompact's frame archive has no
2290
2447
  // stable SessionEntry preserveData slot to carry across future advisor
2291
2448
  // maintenance runs. Use an LLM summary even when the primary session is
2292
2449
  // configured for snapcompact.
2293
- const availableModels = this.#modelRegistry.getAvailable();
2294
- const candidates = this.#resolveCompactionModelCandidates(advisorModel, availableModels);
2295
- if (candidates.length === 0) {
2296
- // No compaction candidates, fallback to re-prime
2297
- return true;
2298
- }
2299
2450
 
2300
2451
  let compactResult: CompactionResult | undefined;
2301
2452
  let lastError: unknown;
2302
- const advisorSessionId = this.sessionId ? `${this.sessionId}-advisor` : undefined;
2303
2453
  // Instrument the advisor's overflow-compaction one-shot like the primary
2304
2454
  // compaction path so the advisor model's maintenance call also emits spans.
2305
- const telemetry = resolveTelemetry(advisor.telemetry, advisorSessionId);
2455
+ const telemetry = resolveTelemetry(agent.telemetry, advisorSessionId);
2306
2456
 
2307
2457
  for (const candidate of candidates) {
2308
2458
  const apiKey = await this.#modelRegistry.getApiKey(candidate, advisorSessionId);
@@ -2319,6 +2469,9 @@ export class AgentSession {
2319
2469
  thinkingLevel: advisorCompactionThinkingLevel,
2320
2470
  convertToLlm: messages => this.#convertToLlmForSideRequest(messages),
2321
2471
  telemetry,
2472
+ tools: agent.state.tools,
2473
+ sessionId: advisorSessionId,
2474
+ promptCacheKey: advisorSessionId,
2322
2475
  },
2323
2476
  );
2324
2477
  break;
@@ -2343,7 +2496,7 @@ export class AgentSession {
2343
2496
  firstKeptEntryId,
2344
2497
  } as CompactionSummaryMessage & { firstKeptEntryId?: string };
2345
2498
 
2346
- advisor.replaceMessages([summaryMessage, ...preparation.recentMessages]);
2499
+ agent.replaceMessages([summaryMessage, ...preparation.recentMessages]);
2347
2500
  return false;
2348
2501
  }
2349
2502
 
@@ -2776,6 +2929,37 @@ export class AgentSession {
2776
2929
  }
2777
2930
  }
2778
2931
 
2932
+ /**
2933
+ * On a user-interrupted (`Esc`) abort, copy the trailing thinking run into a
2934
+ * hidden `display: false` continuity message for the next turn WITHOUT
2935
+ * mutating the assistant message. The original thinking stays on the message
2936
+ * so live render, reload, and Ctrl+L rebuilds keep showing it; `convertToLlm`
2937
+ * strips the run from the provider request (incomplete/unsigned thinking is
2938
+ * rejected on resend) when this continuity message follows the assistant turn.
2939
+ */
2940
+ #demoteInterruptedThinkingOnUserInterrupt(
2941
+ message: AssistantMessage,
2942
+ ): CustomMessage<InterruptedThinkingDetails> | undefined {
2943
+ if (message.stopReason !== "aborted" || !isUserInterruptAbort(message)) return undefined;
2944
+ const demoted = demoteInterruptedThinking(message);
2945
+ if (!demoted) return undefined;
2946
+ const interruptedAt = Date.now();
2947
+ return {
2948
+ role: "custom",
2949
+ customType: INTERRUPTED_THINKING_MESSAGE_TYPE,
2950
+ content: prompt.render(interruptedThinkingTemplate, { reasoning: demoted.reasoning }),
2951
+ display: false,
2952
+ details: {
2953
+ interruptedAt,
2954
+ provider: message.provider,
2955
+ model: message.model,
2956
+ blockCount: demoted.blockCount,
2957
+ },
2958
+ attribution: "agent",
2959
+ timestamp: interruptedAt,
2960
+ };
2961
+ }
2962
+
2779
2963
  async #persistTurnMessagesForMidRunCompaction(context: AgentTurnEndContext | undefined): Promise<boolean> {
2780
2964
  if (!context) return true;
2781
2965
  const turnMessages = [context.message, ...context.toolResults];
@@ -2843,6 +3027,17 @@ export class AgentSession {
2843
3027
  }
2844
3028
  }
2845
3029
 
3030
+ const interruptedThinkingMessage =
3031
+ event.type === "message_end" && event.message.role === "assistant"
3032
+ ? this.#demoteInterruptedThinkingOnUserInterrupt(event.message as AssistantMessage)
3033
+ : undefined;
3034
+ // `message_end` handling is fire-and-forget from agent-core. Make the
3035
+ // hidden continuity turn visible to the next prompt before any awaited
3036
+ // extension delivery or persistence can stall this handler.
3037
+ if (interruptedThinkingMessage) {
3038
+ this.agent.appendMessage(interruptedThinkingMessage);
3039
+ }
3040
+
2846
3041
  const messageEndPersistence =
2847
3042
  event.type === "message_end" ? this.#createMessageEndPersistenceSlot(event.message) : undefined;
2848
3043
 
@@ -2985,6 +3180,15 @@ export class AgentSession {
2985
3180
  } else {
2986
3181
  persistMessageEnd();
2987
3182
  }
3183
+ if (interruptedThinkingMessage) {
3184
+ this.sessionManager.appendCustomMessageEntry(
3185
+ interruptedThinkingMessage.customType,
3186
+ interruptedThinkingMessage.content,
3187
+ interruptedThinkingMessage.display,
3188
+ interruptedThinkingMessage.details,
3189
+ interruptedThinkingMessage.attribution,
3190
+ );
3191
+ }
2988
3192
  // Other message types (bashExecution, compactionSummary, branchSummary) are persisted elsewhere
2989
3193
 
2990
3194
  // Track assistant message for auto-compaction (checked on agent_end)
@@ -3041,9 +3245,10 @@ export class AgentSession {
3041
3245
  }
3042
3246
  }
3043
3247
  if (event.message.role === "toolResult") {
3044
- const { toolName, details, isError, content } = event.message as {
3248
+ const { toolName, toolCallId, details, isError, content } = event.message as {
3249
+ toolCallId?: string;
3045
3250
  toolName?: string;
3046
- details?: { path?: string; phases?: TodoPhase[]; report?: string; startedAt?: string };
3251
+ details?: { op?: string; path?: string; phases?: TodoPhase[]; report?: string; startedAt?: string };
3047
3252
  isError?: boolean;
3048
3253
  content?: Array<TextContent | ImageContent>;
3049
3254
  };
@@ -3057,6 +3262,9 @@ export class AgentSession {
3057
3262
  }
3058
3263
  if (toolName === "todo" && !isError && Array.isArray(details?.phases)) {
3059
3264
  this.setTodoPhases(details.phases);
3265
+ if (this.#isTodoInitResult(details, toolCallId)) {
3266
+ this.#scheduleReplanTitleRefresh();
3267
+ }
3060
3268
  }
3061
3269
  if (toolName === "todo" && isError) {
3062
3270
  const errorText = content?.find(part => part.type === "text")?.text;
@@ -6526,7 +6734,10 @@ export class AgentSession {
6526
6734
 
6527
6735
  async promptCustomMessage<T = unknown>(
6528
6736
  message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details" | "attribution">,
6529
- options?: Pick<PromptOptions, "streamingBehavior" | "toolChoice"> & { queueChipText?: string },
6737
+ options?: Pick<PromptOptions, "streamingBehavior" | "toolChoice"> & {
6738
+ queueChipText?: string;
6739
+ queueOnly?: boolean;
6740
+ },
6530
6741
  ): Promise<void> {
6531
6742
  const textContent =
6532
6743
  typeof message.content === "string"
@@ -6546,6 +6757,16 @@ export class AgentSession {
6546
6757
  keywordNotices = this.#createMagicKeywordNotices(skillArgs);
6547
6758
  }
6548
6759
 
6760
+ if (options?.queueOnly) {
6761
+ if (!options.streamingBehavior) {
6762
+ throw new AgentBusyError();
6763
+ }
6764
+ for (const notice of keywordNotices) {
6765
+ await this.#queueCustomMessage(notice, options.streamingBehavior);
6766
+ }
6767
+ await this.#queueCustomMessage(message, options.streamingBehavior, options.queueChipText);
6768
+ return;
6769
+ }
6549
6770
  if (this.isStreaming) {
6550
6771
  if (!options?.streamingBehavior) {
6551
6772
  throw new AgentBusyError();
@@ -7111,6 +7332,40 @@ export class AgentSession {
7111
7332
  }
7112
7333
  }
7113
7334
 
7335
+ /** Queue a custom message without starting a turn, matching steer/follow-up delivery. */
7336
+ async #queueCustomMessage<T = unknown>(
7337
+ message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details" | "attribution">,
7338
+ deliverAs: "steer" | "followUp",
7339
+ queueChipText?: string,
7340
+ ): Promise<void> {
7341
+ const details =
7342
+ queueChipText !== undefined
7343
+ ? ({
7344
+ ...((message.details && typeof message.details === "object" ? message.details : {}) as Record<
7345
+ string,
7346
+ unknown
7347
+ >),
7348
+ __queueChipText: queueChipText,
7349
+ } as T)
7350
+ : message.details;
7351
+ const appMessage: CustomMessage<T> = {
7352
+ role: "custom",
7353
+ customType: message.customType,
7354
+ content: message.content,
7355
+ display: message.display,
7356
+ details,
7357
+ attribution: message.attribution ?? "agent",
7358
+ timestamp: Date.now(),
7359
+ };
7360
+ const normalizedAppMessage = await this.#normalizeAgentMessageImages(appMessage);
7361
+ if (deliverAs === "followUp") {
7362
+ this.agent.followUp(normalizedAppMessage);
7363
+ } else {
7364
+ this.agent.steer(normalizedAppMessage);
7365
+ }
7366
+ this.#scheduleIdleQueueDrain();
7367
+ }
7368
+
7114
7369
  /**
7115
7370
  * Send a custom message to the session. Creates a CustomMessageEntry.
7116
7371
  *
@@ -7353,6 +7608,74 @@ export class AgentSession {
7353
7608
  this.#todoPhases = this.#cloneTodoPhases(phases);
7354
7609
  }
7355
7610
 
7611
+ #isTodoInitResult(details: Record<string, unknown>, toolCallId: string | undefined): boolean {
7612
+ const detailOp = getStringProperty(details, "op");
7613
+ if (detailOp) return detailOp === "init";
7614
+ if (!toolCallId) return false;
7615
+ for (let i = this.agent.state.messages.length - 1; i >= 0; i--) {
7616
+ const message = this.agent.state.messages[i];
7617
+ if (!message) continue;
7618
+ const op = toolCallOpFromMessage(message, toolCallId);
7619
+ if (op) return op === "init";
7620
+ }
7621
+ return false;
7622
+ }
7623
+
7624
+ #buildReplanTitleContext(): string {
7625
+ const turns: TitleConversationTurn[] = [];
7626
+ for (
7627
+ let i = this.agent.state.messages.length - 1;
7628
+ i >= 0 && turns.length < REPLAN_TITLE_CONTEXT_TURN_LIMIT;
7629
+ i--
7630
+ ) {
7631
+ const message = this.agent.state.messages[i];
7632
+ if (!message) continue;
7633
+ const turn = titleConversationTurnFromMessage(message);
7634
+ if (turn) turns.push(turn);
7635
+ }
7636
+ turns.reverse();
7637
+ return formatTitleConversationContext(turns);
7638
+ }
7639
+
7640
+ #scheduleReplanTitleRefresh(): void {
7641
+ if (this.#replanTitleRefreshInFlight) return;
7642
+ if (!this.settings.get("title.refreshOnReplan")) return;
7643
+ if (this.sessionManager.titleSource === "user") return;
7644
+ const context = this.#buildReplanTitleContext();
7645
+ if (!context) return;
7646
+ const sessionId = this.sessionManager.getSessionId();
7647
+ const refresh = this.#refreshTitleAfterReplan(context, sessionId)
7648
+ .catch(err => {
7649
+ logger.warn("title-generator: replan refresh failed", {
7650
+ sessionId,
7651
+ error: err instanceof Error ? err.message : String(err),
7652
+ });
7653
+ })
7654
+ .finally(() => {
7655
+ if (this.#replanTitleRefreshInFlight === refresh) {
7656
+ this.#replanTitleRefreshInFlight = undefined;
7657
+ }
7658
+ });
7659
+ this.#replanTitleRefreshInFlight = refresh;
7660
+ }
7661
+
7662
+ async #refreshTitleAfterReplan(context: string, sessionId: string): Promise<void> {
7663
+ const title = await generateSessionTitle(
7664
+ context,
7665
+ this.#modelRegistry,
7666
+ this.settings,
7667
+ sessionId,
7668
+ this.model,
7669
+ provider => this.agent.metadataForProvider(provider),
7670
+ );
7671
+ if (!title) return;
7672
+ if (this.sessionManager.getSessionId() !== sessionId) return;
7673
+ if (!this.settings.get("title.refreshOnReplan")) return;
7674
+ if (this.sessionManager.titleSource === "user") return;
7675
+ const setSessionName = this.sessionManager.setSessionName as SetSessionNameWithTrigger;
7676
+ await setSessionName.call(this.sessionManager, title, "auto", "replan");
7677
+ }
7678
+
7356
7679
  #syncTodoPhasesFromBranch(): void {
7357
7680
  const phases = getLatestTodoPhasesFromEntries(this.sessionManager.getBranch());
7358
7681
  // Strip completed/abandoned tasks — they were done in a previous run,
@@ -7495,9 +7818,11 @@ export class AgentSession {
7495
7818
  // running advisor turn could otherwise finish, emit `message_end`, and recreate
7496
7819
  // `<old>/__advisor.jsonl`. #resetAdvisorSessionState (after newSession) re-primes
7497
7820
  // the advisor and re-attaches the feed at the new session's path.
7498
- this.#advisorAgentUnsubscribe?.();
7499
- this.#advisorAgentUnsubscribe = undefined;
7500
- if (this.#advisorTranscriptRecorder) await this.#advisorTranscriptRecorder.close();
7821
+ for (const a of this.#advisors) {
7822
+ a.agentUnsubscribe?.();
7823
+ a.agentUnsubscribe = undefined;
7824
+ await a.recorder.close();
7825
+ }
7501
7826
  try {
7502
7827
  await this.sessionManager.dropSession(previousSessionFile);
7503
7828
  } catch (err) {
@@ -7551,8 +7876,9 @@ export class AgentSession {
7551
7876
  /**
7552
7877
  * Set a display name for the current session.
7553
7878
  */
7554
- setSessionName(name: string, source: "auto" | "user" = "auto"): Promise<boolean> {
7555
- return this.sessionManager.setSessionName(name, source);
7879
+ setSessionName(name: string, source: "auto" | "user" = "auto", trigger?: SessionNameTrigger): Promise<boolean> {
7880
+ const setSessionName = this.sessionManager.setSessionName as SetSessionNameWithTrigger;
7881
+ return setSessionName.call(this.sessionManager, name, source, trigger);
7556
7882
  }
7557
7883
 
7558
7884
  /**
@@ -7630,16 +7956,24 @@ export class AgentSession {
7630
7956
  /**
7631
7957
  * Set model directly.
7632
7958
  * Validates that a credential source is configured (synchronously, without
7633
- * refreshing OAuth or running command-backed key programs) and saves to the
7634
- * active session. Persists settings only when requested. The concrete key is
7635
- * resolved lazily per request, so switching never blocks the event loop.
7959
+ * refreshing OAuth or running command-backed key programs). The active
7960
+ * session switches by default; when `currentContextTokens` is provided and
7961
+ * exceeds the refreshed candidate's context window, the live switch is
7962
+ * skipped while role persistence still runs. Returns whether the active
7963
+ * model actually switched, computed against the refreshed metadata so
7964
+ * dynamic providers (e.g. llama.cpp) honor their post-load contextWindow.
7636
7965
  * @throws Error if no API key available for the model
7637
7966
  */
7638
7967
  async setModel(
7639
7968
  model: Model,
7640
7969
  role: string = "default",
7641
- options?: { selector?: string; thinkingLevel?: ThinkingLevel; persist?: boolean },
7642
- ): Promise<void> {
7970
+ options?: {
7971
+ selector?: string;
7972
+ thinkingLevel?: ThinkingLevel;
7973
+ persist?: boolean;
7974
+ currentContextTokens?: number;
7975
+ },
7976
+ ): Promise<{ switched: boolean }> {
7643
7977
  const previousEditMode = this.#resolveActiveEditMode();
7644
7978
  if (!this.#modelRegistry.hasConfiguredAuth(model)) {
7645
7979
  throw new Error(`No API key for ${model.provider}/${model.id}`);
@@ -7647,21 +7981,35 @@ export class AgentSession {
7647
7981
 
7648
7982
  const targetModel = await this.#modelRegistry.refreshSelectedModelMetadata(model);
7649
7983
 
7650
- this.#clearActiveRetryFallback();
7651
- this.#setModelWithProviderSessionReset(targetModel);
7652
- this.sessionManager.appendModelChange(`${targetModel.provider}/${targetModel.id}`, role);
7984
+ const currentContextTokens = options?.currentContextTokens ?? 0;
7985
+ const targetContextWindow = targetModel.contextWindow ?? 0;
7986
+ const switched = !(
7987
+ currentContextTokens > 0 &&
7988
+ targetContextWindow > 0 &&
7989
+ currentContextTokens > targetContextWindow
7990
+ );
7991
+
7992
+ if (switched) {
7993
+ this.#clearActiveRetryFallback();
7994
+ this.#setModelWithProviderSessionReset(targetModel);
7995
+ this.sessionManager.appendModelChange(`${targetModel.provider}/${targetModel.id}`, role);
7996
+ }
7653
7997
  if (options?.persist) {
7654
7998
  this.settings.setModelRole(
7655
7999
  role,
7656
8000
  this.#formatRoleModelValue(role, targetModel, options.selector, options.thinkingLevel),
7657
8001
  );
7658
8002
  }
8003
+ if (!switched) {
8004
+ return { switched: false };
8005
+ }
7659
8006
  this.settings.getStorage()?.recordModelUsage(`${targetModel.provider}/${targetModel.id}`);
7660
8007
 
7661
8008
  // Re-apply thinking for the newly selected model. Prefer the model's
7662
8009
  // configured defaultLevel; otherwise preserve the current level (or auto).
7663
8010
  this.#reapplyThinkingLevel(targetModel.thinking?.defaultLevel);
7664
8011
  await this.#syncAfterModelChange(previousEditMode);
8012
+ return { switched: true };
7665
8013
  }
7666
8014
 
7667
8015
  /**
@@ -8186,7 +8534,7 @@ export class AgentSession {
8186
8534
  await this.sessionManager.rewriteEntries();
8187
8535
  const sessionContext = this.buildDisplaySessionContext();
8188
8536
  this.agent.replaceMessages(sessionContext.messages);
8189
- this.#advisorRuntime?.reset();
8537
+ this.#resetAllAdvisorRuntimes();
8190
8538
  this.#syncTodoPhasesFromBranch();
8191
8539
  this.#closeCodexProviderSessionsForHistoryRewrite();
8192
8540
  return result;
@@ -8223,7 +8571,7 @@ export class AgentSession {
8223
8571
 
8224
8572
  const sessionContext = this.buildDisplaySessionContext();
8225
8573
  this.agent.replaceMessages(sessionContext.messages);
8226
- this.#advisorRuntime?.reset();
8574
+ this.#resetAllAdvisorRuntimes();
8227
8575
  this.#syncTodoPhasesFromBranch();
8228
8576
  this.#closeCodexProviderSessionsForHistoryRewrite();
8229
8577
  return result;
@@ -8274,7 +8622,7 @@ export class AgentSession {
8274
8622
  await this.sessionManager.rewriteEntries();
8275
8623
  const sessionContext = this.buildDisplaySessionContext();
8276
8624
  this.agent.replaceMessages(sessionContext.messages);
8277
- this.#advisorRuntime?.reset();
8625
+ this.#resetAllAdvisorRuntimes();
8278
8626
  this.#closeCodexProviderSessionsForHistoryRewrite();
8279
8627
  return { removed };
8280
8628
  }
@@ -8331,7 +8679,7 @@ export class AgentSession {
8331
8679
  await this.sessionManager.rewriteEntries();
8332
8680
  const sessionContext = this.buildDisplaySessionContext();
8333
8681
  this.agent.replaceMessages(sessionContext.messages);
8334
- this.#advisorRuntime?.reset();
8682
+ this.#resetAllAdvisorRuntimes();
8335
8683
  this.#closeCodexProviderSessionsForHistoryRewrite();
8336
8684
 
8337
8685
  return {
@@ -8427,7 +8775,11 @@ export class AgentSession {
8427
8775
  compactionCandidates = this.#getCompactionModelCandidates(availableModels);
8428
8776
  }
8429
8777
  const pathEntries = this.sessionManager.getBranch();
8430
- const preparation = prepareCompaction(pathEntries, effectiveSettings);
8778
+ const preparation = prepareCompaction(
8779
+ pathEntries,
8780
+ effectiveSettings,
8781
+ await this.#runnableCompactionCandidates(compactionCandidates, this.sessionId),
8782
+ );
8431
8783
  if (!preparation) {
8432
8784
  // Check why we can't compact
8433
8785
  const lastEntry = pathEntries[pathEntries.length - 1];
@@ -8617,7 +8969,7 @@ export class AgentSession {
8617
8969
  // plan reference. Clear the sent-flag so #buildPlanReferenceMessage re-reads
8618
8970
  // the plan from disk and re-injects it on the next turn (issue #1246).
8619
8971
  this.#planReferenceSent = false;
8620
- this.#advisorRuntime?.reset();
8972
+ this.#resetAllAdvisorRuntimes();
8621
8973
  this.#syncTodoPhasesFromBranch();
8622
8974
  this.#closeCodexProviderSessionsForHistoryRewrite();
8623
8975
 
@@ -8881,7 +9233,7 @@ export class AgentSession {
8881
9233
  // Rebuild agent messages from session
8882
9234
  const sessionContext = this.buildDisplaySessionContext();
8883
9235
  this.agent.replaceMessages(sessionContext.messages);
8884
- this.#advisorRuntime?.reset();
9236
+ this.#resetAllAdvisorRuntimes();
8885
9237
  this.#syncTodoPhasesFromBranch();
8886
9238
 
8887
9239
  return { document: handoffText, savedPath };
@@ -10201,6 +10553,17 @@ export class AgentSession {
10201
10553
  return this.#resolveCompactionModelCandidates(this.model, availableModels, filter);
10202
10554
  }
10203
10555
 
10556
+ /**
10557
+ * Compaction candidates that can actually run — those with a resolvable API
10558
+ * key, matching the per-candidate getApiKey gate the execution loop applies.
10559
+ * Re-expansion reusability (prepareCompaction) must judge remote-preserve
10560
+ * reuse against these, not against candidates the loop would skip at runtime.
10561
+ */
10562
+ async #runnableCompactionCandidates(candidates: readonly Model[], sessionId: string | undefined): Promise<Model[]> {
10563
+ const keys = await Promise.all(candidates.map(model => this.#modelRegistry.getApiKey(model, sessionId)));
10564
+ return candidates.filter((_, index) => keys[index] !== undefined);
10565
+ }
10566
+
10204
10567
  #resolveCompactionModelCandidates(
10205
10568
  preferredModel: Model | null | undefined,
10206
10569
  availableModels: Model[],
@@ -10297,6 +10660,9 @@ export class AgentSession {
10297
10660
  // via resolveCompactionEffort so unsupported-effort models
10298
10661
  // (xai-oauth/grok-build) don't trip requireSupportedEffort.
10299
10662
  thinkingLevel: this.thinkingLevel,
10663
+ tools: this.agent.state.tools,
10664
+ sessionId: this.sessionId,
10665
+ promptCacheKey: this.sessionId,
10300
10666
  },
10301
10667
  );
10302
10668
  } catch (error) {
@@ -10616,15 +10982,21 @@ export class AgentSession {
10616
10982
 
10617
10983
  // "overflow" forces context-full because the input itself is broken — a handoff
10618
10984
  // LLM call would hit the same overflow. "incomplete" is an output-side problem,
10619
- // so a handoff request on the existing context is still viable. Snapcompact is
10620
- // a local-only strategy: if it cannot run, report the local blocker instead of
10621
- // silently swapping in a provider-backed summary.
10985
+ // so a handoff request on the existing context is still viable.
10622
10986
  let action: "context-full" | "handoff" | "snapcompact" =
10623
10987
  compactionSettings.strategy === "snapcompact"
10624
10988
  ? "snapcompact"
10625
10989
  : compactionSettings.strategy === "handoff" && reason !== "overflow" && !suppressHandoff
10626
10990
  ? "handoff"
10627
10991
  : "context-full";
10992
+ if (action === "snapcompact" && this.model && !this.model.input.includes("image")) {
10993
+ this.emitNotice(
10994
+ "warning",
10995
+ `snapcompact needs a vision-capable active model (${this.model.id} is text-only); using context-full auto-compaction instead.`,
10996
+ "compaction",
10997
+ );
10998
+ action = "context-full";
10999
+ }
10628
11000
  // Abort any older auto-compaction before installing this run's controller.
10629
11001
  this.#autoCompactionAbortController?.abort();
10630
11002
  const autoCompactionAbortController = new AbortController();
@@ -10703,7 +11075,11 @@ export class AgentSession {
10703
11075
 
10704
11076
  const pathEntries = this.sessionManager.getBranch();
10705
11077
 
10706
- const preparation = prepareCompaction(pathEntries, compactionSettings);
11078
+ const autoCompactionCandidates = await this.#runnableCompactionCandidates(
11079
+ this.#getCompactionModelCandidates(availableModels),
11080
+ this.sessionId,
11081
+ );
11082
+ const preparation = prepareCompaction(pathEntries, compactionSettings, autoCompactionCandidates);
10707
11083
  if (!preparation) {
10708
11084
  await this.#emitSessionEvent({
10709
11085
  type: "auto_compaction_end",
@@ -10766,21 +11142,15 @@ export class AgentSession {
10766
11142
  // + a summary message carrying the imaged archive at FRAME_TOKEN_ESTIMATE
10767
11143
  // per frame; #computeSnapcompactMaxFrames sizes the frame cap from the
10768
11144
  // live window so we don't run snapcompact just to overflow every threshold
10769
- // tick. Any local blocker fails the snapcompact maintenance pass rather
10770
- // than falling back to a provider-backed LLM summary.
11145
+ // tick. Any local blocker (non-ASCII transcript, kept-history too large,
11146
+ // post-render overflow) downgrades auto maintenance to a context-full LLM
11147
+ // summary instead of wedging the session (#3659) — auto runs the default
11148
+ // strategy on the user's behalf, so a fallback that lets the session keep
11149
+ // running is the right behavior. Manual `/compact snapcompact` keeps the
11150
+ // local-only contract (#3599): the user explicitly picked it.
10771
11151
  let snapcompactResult: snapcompact.CompactionResult | undefined;
11152
+ let snapcompactBlocker: string | undefined;
10772
11153
  if (action === "snapcompact" && compactionPrep.kind !== "fromHook") {
10773
- if (!this.model?.input.includes("image")) {
10774
- logger.warn("Snapcompact compaction requires a vision-capable model", {
10775
- model: this.model?.id,
10776
- });
10777
- this.emitNotice(
10778
- "warning",
10779
- `snapcompact needs a vision-capable model (${this.model?.id ?? "unknown"} is text-only). No LLM fallback was attempted.`,
10780
- "compaction",
10781
- );
10782
- throw new Error(`snapcompact cannot run locally: ${this.model?.id ?? "unknown"} is text-only.`);
10783
- }
10784
11154
  const text = snapcompact.serializeConversation(
10785
11155
  convertToLlm(preparation.messagesToSummarize.concat(preparation.turnPrefixMessages)),
10786
11156
  );
@@ -10791,55 +11161,46 @@ export class AgentSession {
10791
11161
  model: this.model?.id,
10792
11162
  unrenderableRatio: renderScan.unrenderableRatio,
10793
11163
  });
10794
- this.emitNotice(
10795
- "warning",
10796
- `snapcompact disabled: high non-ASCII rate detected (${percent}%). No LLM fallback was attempted.`,
10797
- "compaction",
10798
- );
10799
- throw new Error(
10800
- `snapcompact cannot render this conversation locally: high non-ASCII rate detected (${percent}%).`,
10801
- );
10802
- }
10803
- const maxFrames = this.#computeSnapcompactMaxFrames(preparation, compactionSettings);
10804
- if (maxFrames < 1) {
10805
- logger.warn("Snapcompact skipped: kept history alone exceeds the context budget", {
10806
- model: this.model?.id,
10807
- });
10808
- this.emitNotice(
10809
- "warning",
10810
- "snapcompact: kept history alone exceeds the context budget. No LLM fallback was attempted.",
10811
- "compaction",
10812
- );
10813
- throw new Error("snapcompact cannot run locally: kept history alone exceeds the context budget.");
10814
- }
10815
- snapcompactResult = await snapcompact.compact(preparation, {
10816
- convertToLlm,
10817
- model: this.model,
10818
- shape: snapcompact.resolveShape(this.model, this.settings.get("snapcompact.shape")),
10819
- maxFrames,
10820
- });
10821
-
10822
- if (snapcompactResult) {
10823
- const ctxWindow = this.model?.contextWindow ?? 0;
10824
- const budget =
10825
- ctxWindow > 0
10826
- ? ctxWindow - effectiveReserveTokens(ctxWindow, compactionSettings)
10827
- : Number.POSITIVE_INFINITY;
10828
- const projected = this.#projectSnapcompactContextTokens(preparation, snapcompactResult);
10829
- if (projected > budget) {
10830
- logger.warn("Snapcompact still overflows the window after frame-budget sizing", {
11164
+ snapcompactBlocker = `snapcompact disabled: high non-ASCII rate detected (${percent}%); using context-full auto-compaction instead.`;
11165
+ } else {
11166
+ const maxFrames = this.#computeSnapcompactMaxFrames(preparation, compactionSettings);
11167
+ if (maxFrames < 1) {
11168
+ logger.warn("Snapcompact skipped: kept history alone exceeds the context budget", {
10831
11169
  model: this.model?.id,
10832
- projected,
10833
- budget,
10834
11170
  });
10835
- this.emitNotice(
10836
- "warning",
10837
- "snapcompact could not bring the context under the limit. No LLM fallback was attempted.",
10838
- "compaction",
10839
- );
10840
- throw new Error("snapcompact could not bring the context under the limit locally.");
11171
+ snapcompactBlocker =
11172
+ "snapcompact: kept history alone exceeds the context budget; using context-full auto-compaction instead.";
11173
+ } else {
11174
+ snapcompactResult = await snapcompact.compact(preparation, {
11175
+ convertToLlm,
11176
+ model: this.model,
11177
+ shape: snapcompact.resolveShape(this.model, this.settings.get("snapcompact.shape")),
11178
+ maxFrames,
11179
+ });
11180
+ if (snapcompactResult) {
11181
+ const ctxWindow = this.model?.contextWindow ?? 0;
11182
+ const budget =
11183
+ ctxWindow > 0
11184
+ ? ctxWindow - effectiveReserveTokens(ctxWindow, compactionSettings)
11185
+ : Number.POSITIVE_INFINITY;
11186
+ const projected = this.#projectSnapcompactContextTokens(preparation, snapcompactResult);
11187
+ if (projected > budget) {
11188
+ logger.warn("Snapcompact still overflows the window after frame-budget sizing", {
11189
+ model: this.model?.id,
11190
+ projected,
11191
+ budget,
11192
+ });
11193
+ snapcompactBlocker =
11194
+ "snapcompact could not bring the context under the limit; using context-full auto-compaction instead.";
11195
+ snapcompactResult = undefined;
11196
+ }
11197
+ }
10841
11198
  }
10842
11199
  }
11200
+ if (snapcompactBlocker) {
11201
+ this.emitNotice("warning", snapcompactBlocker, "compaction");
11202
+ action = "context-full";
11203
+ }
10843
11204
  }
10844
11205
 
10845
11206
  if (compactionPrep.kind === "fromHook") {
@@ -10891,6 +11252,9 @@ export class AgentSession {
10891
11252
  // site. Clamped per-model inside compact() via
10892
11253
  // resolveCompactionEffort.
10893
11254
  thinkingLevel: this.thinkingLevel,
11255
+ tools: this.agent.state.tools,
11256
+ sessionId: this.sessionId,
11257
+ promptCacheKey: this.sessionId,
10894
11258
  },
10895
11259
  );
10896
11260
  break;
@@ -11007,7 +11371,7 @@ export class AgentSession {
11007
11371
  // plan reference. Clear the sent-flag so #buildPlanReferenceMessage re-reads
11008
11372
  // the plan from disk and re-injects it on the next turn (issue #1246).
11009
11373
  this.#planReferenceSent = false;
11010
- this.#advisorRuntime?.reset();
11374
+ this.#resetAllAdvisorRuntimes();
11011
11375
  this.#syncTodoPhasesFromBranch();
11012
11376
  this.#closeCodexProviderSessionsForHistoryRewrite();
11013
11377
 
@@ -12853,7 +13217,7 @@ export class AgentSession {
12853
13217
  this.#applyThinkingLevelToAgent(previousThinkingLevel);
12854
13218
  this.agent.serviceTier = previousServiceTier;
12855
13219
  this.#syncTodoPhasesFromBranch();
12856
- this.#advisorRuntime?.reset();
13220
+ this.#resetAllAdvisorRuntimes();
12857
13221
  this.#reconnectToAgent();
12858
13222
  if (restoreMcpError) {
12859
13223
  throw restoreMcpError;
@@ -13255,10 +13619,11 @@ export class AgentSession {
13255
13619
  let totalInput = 0;
13256
13620
  let totalOutput = 0;
13257
13621
  let totalCacheRead = 0;
13622
+ let totalReasoning = 0;
13258
13623
  let totalCacheWrite = 0;
13259
13624
  let totalCost = 0;
13260
-
13261
13625
  let totalPremiumRequests = 0;
13626
+
13262
13627
  const getTaskToolUsage = (details: unknown): Usage | undefined => {
13263
13628
  if (!details || typeof details !== "object") return undefined;
13264
13629
  const record = details as Record<string, unknown>;
@@ -13273,6 +13638,7 @@ export class AgentSession {
13273
13638
  toolCalls += assistantMsg.content.filter(c => c.type === "toolCall").length;
13274
13639
  totalInput += assistantMsg.usage.input;
13275
13640
  totalOutput += assistantMsg.usage.output;
13641
+ totalReasoning += assistantMsg.usage.reasoningTokens ?? 0;
13276
13642
  totalCacheRead += assistantMsg.usage.cacheRead;
13277
13643
  totalCacheWrite += assistantMsg.usage.cacheWrite;
13278
13644
  totalPremiumRequests += assistantMsg.usage.premiumRequests ?? 0;
@@ -13284,6 +13650,7 @@ export class AgentSession {
13284
13650
  if (usage) {
13285
13651
  totalInput += usage.input;
13286
13652
  totalOutput += usage.output;
13653
+ totalReasoning += usage.reasoningTokens ?? 0;
13287
13654
  totalCacheRead += usage.cacheRead;
13288
13655
  totalCacheWrite += usage.cacheWrite;
13289
13656
  totalPremiumRequests += usage.premiumRequests ?? 0;
@@ -13303,6 +13670,7 @@ export class AgentSession {
13303
13670
  tokens: {
13304
13671
  input: totalInput,
13305
13672
  output: totalOutput,
13673
+ reasoning: totalReasoning,
13306
13674
  cacheRead: totalCacheRead,
13307
13675
  cacheWrite: totalCacheWrite,
13308
13676
  total: totalInput + totalOutput + totalCacheRead + totalCacheWrite,
@@ -13835,6 +14203,23 @@ export class AgentSession {
13835
14203
  return this.setAdvisorEnabled(!this.#advisorEnabled);
13836
14204
  }
13837
14205
 
14206
+ /**
14207
+ * Replace the live advisor roster from an edited `WATCHDOG.yml` (the `/advisor
14208
+ * configure` save path). Swaps the configs + shared baseline, then rebuilds the
14209
+ * runtimes in place so the change applies without a restart. When the advisor is
14210
+ * disabled the new configs are simply stored for the next enable.
14211
+ *
14212
+ * @returns the number of advisors active after the rebuild.
14213
+ */
14214
+ applyAdvisorConfigs(advisors: AdvisorConfig[], sharedInstructions: string | undefined): number {
14215
+ this.#advisorConfigs = advisors;
14216
+ this.#advisorSharedInstructions = sharedInstructions;
14217
+ if (!this.#advisorEnabled) return 0;
14218
+ this.#stopAdvisorRuntime();
14219
+ this.#buildAdvisorRuntime(true);
14220
+ return this.#advisors.length;
14221
+ }
14222
+
13838
14223
  /**
13839
14224
  * Whether the advisor setting is enabled for this session.
13840
14225
  */
@@ -13849,7 +14234,17 @@ export class AgentSession {
13849
14234
  * not merely the setting. Drives the status-line badge and `/dump advisor`.
13850
14235
  */
13851
14236
  isAdvisorActive(): boolean {
13852
- return this.#advisorAgent !== undefined;
14237
+ return this.#advisors.length > 0;
14238
+ }
14239
+
14240
+ /**
14241
+ * The names of the tools available to advisors this session (the pool a
14242
+ * `/advisor configure` editor lists). The advisor is a full agent, so this is the
14243
+ * full built tool set; a tool whose optional factory returns null (e.g. lsp with
14244
+ * no servers) is absent.
14245
+ */
14246
+ getAdvisorAvailableToolNames(): string[] {
14247
+ return (this.#advisorTools ?? []).map(tool => tool.name);
13853
14248
  }
13854
14249
 
13855
14250
  /**
@@ -13860,7 +14255,7 @@ export class AgentSession {
13860
14255
  * (`streamFn`, `promptCacheKey`, `providerSessionState`, ...).
13861
14256
  */
13862
14257
  getAdvisorAgent(): Agent | undefined {
13863
- return this.#advisorAgent;
14258
+ return this.#advisors[0]?.agent;
13864
14259
  }
13865
14260
 
13866
14261
  /**
@@ -13868,23 +14263,59 @@ export class AgentSession {
13868
14263
  */
13869
14264
  getAdvisorStats(): AdvisorStats {
13870
14265
  const configured = this.#advisorEnabled;
13871
- const advisor = this.#advisorAgent;
13872
- if (!advisor) {
14266
+ const advisors = this.#advisors.map(a => this.#computeAdvisorStat(a));
14267
+ if (advisors.length === 0) {
13873
14268
  return {
13874
14269
  configured,
13875
14270
  active: false,
13876
14271
  contextWindow: 0,
13877
14272
  contextTokens: 0,
13878
- tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
14273
+ tokens: { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
13879
14274
  cost: 0,
13880
14275
  messages: { user: 0, assistant: 0, total: 0 },
14276
+ advisors: [],
13881
14277
  };
13882
14278
  }
13883
- const model = advisor.state.model;
13884
- const messages = advisor.state.messages;
14279
+ const tokens = { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0, total: 0 };
14280
+ const messages = { user: 0, assistant: 0, total: 0 };
14281
+ let cost = 0;
14282
+ let contextTokens = 0;
14283
+ for (const a of advisors) {
14284
+ tokens.input += a.tokens.input;
14285
+ tokens.output += a.tokens.output;
14286
+ tokens.reasoning += a.tokens.reasoning;
14287
+ tokens.cacheRead += a.tokens.cacheRead;
14288
+ tokens.cacheWrite += a.tokens.cacheWrite;
14289
+ tokens.total += a.tokens.total;
14290
+ messages.user += a.messages.user;
14291
+ messages.assistant += a.messages.assistant;
14292
+ messages.total += a.messages.total;
14293
+ cost += a.cost;
14294
+ contextTokens += a.contextTokens;
14295
+ }
14296
+ // Single-advisor displays read the top-level model/window directly; surface the
14297
+ // first advisor's so the legacy status line stays byte-identical.
14298
+ return {
14299
+ configured,
14300
+ active: true,
14301
+ model: advisors[0].model,
14302
+ contextWindow: advisors[0].contextWindow,
14303
+ contextTokens,
14304
+ tokens,
14305
+ cost,
14306
+ messages,
14307
+ advisors,
14308
+ };
14309
+ }
14310
+
14311
+ /** Compute one advisor's stats slice (tokens, cost, context, message counts). */
14312
+ #computeAdvisorStat(advisor: ActiveAdvisor): PerAdvisorStat {
14313
+ const model = advisor.agent.state.model;
14314
+ const messages = advisor.agent.state.messages;
13885
14315
  const contextTokens = this.#estimateAdvisorContextTokens(messages);
13886
14316
  let input = 0;
13887
14317
  let output = 0;
14318
+ let reasoning = 0;
13888
14319
  let cacheRead = 0;
13889
14320
  let cacheWrite = 0;
13890
14321
  let cost = 0;
@@ -13897,24 +14328,18 @@ export class AgentSession {
13897
14328
  const assistantMsg = message as AssistantMessage;
13898
14329
  input += assistantMsg.usage.input;
13899
14330
  output += assistantMsg.usage.output;
14331
+ reasoning += assistantMsg.usage.reasoningTokens ?? 0;
13900
14332
  cacheRead += assistantMsg.usage.cacheRead;
13901
14333
  cacheWrite += assistantMsg.usage.cacheWrite;
13902
14334
  cost += assistantMsg.usage.cost.total;
13903
14335
  }
13904
14336
  }
13905
14337
  return {
13906
- configured,
13907
- active: true,
14338
+ name: advisor.name,
13908
14339
  model,
13909
14340
  contextWindow: model.contextWindow ?? 0,
13910
14341
  contextTokens,
13911
- tokens: {
13912
- input,
13913
- output,
13914
- cacheRead,
13915
- cacheWrite,
13916
- total: input + output + cacheRead + cacheWrite,
13917
- },
14342
+ tokens: { input, output, reasoning, cacheRead, cacheWrite, total: input + output + cacheRead + cacheWrite },
13918
14343
  cost,
13919
14344
  messages: { user, assistant, total: messages.length },
13920
14345
  };
@@ -13930,19 +14355,30 @@ export class AgentSession {
13930
14355
  ? "Advisor setting is enabled, but no model is assigned to the 'advisor' role."
13931
14356
  : "Advisor is disabled.";
13932
14357
  }
13933
- const model = stats.model!;
13934
- const contextLine =
13935
- stats.contextWindow > 0
13936
- ? `Context: ${stats.contextTokens.toLocaleString()} / ${stats.contextWindow.toLocaleString()} tokens (${Math.round((stats.contextTokens / stats.contextWindow) * 100)}%)`
13937
- : `Context: ${stats.contextTokens.toLocaleString()} tokens`;
13938
- const spendParts = [
13939
- `${stats.tokens.input.toLocaleString()} input`,
13940
- `${stats.tokens.output.toLocaleString()} output`,
13941
- ];
13942
- if (stats.tokens.cacheRead > 0) spendParts.push(`${stats.tokens.cacheRead.toLocaleString()} cache read`);
13943
- if (stats.tokens.cacheWrite > 0) spendParts.push(`${stats.tokens.cacheWrite.toLocaleString()} cache write`);
13944
- const spendLine = `Spend: ${spendParts.join(", ")}, $${stats.cost.toFixed(4)}`;
13945
- return `Advisor is enabled (${model.provider}/${model.id}). ${contextLine}. ${spendLine}.`;
14358
+ if (stats.advisors.length <= 1) {
14359
+ const s = stats.advisors[0];
14360
+ const contextLine =
14361
+ s.contextWindow > 0
14362
+ ? `Context: ${s.contextTokens.toLocaleString()} / ${s.contextWindow.toLocaleString()} tokens (${Math.round((s.contextTokens / s.contextWindow) * 100)}%)`
14363
+ : `Context: ${s.contextTokens.toLocaleString()} tokens`;
14364
+ const spendParts = [`${s.tokens.input.toLocaleString()} input`, `${s.tokens.output.toLocaleString()} output`];
14365
+ if (s.tokens.cacheRead > 0) spendParts.push(`${s.tokens.cacheRead.toLocaleString()} cache read`);
14366
+ if (s.tokens.cacheWrite > 0) spendParts.push(`${s.tokens.cacheWrite.toLocaleString()} cache write`);
14367
+ const spendLine = `Spend: ${spendParts.join(", ")}, $${s.cost.toFixed(4)}`;
14368
+ return `Advisor is enabled (${s.model.provider}/${s.model.id}). ${contextLine}. ${spendLine}.`;
14369
+ }
14370
+ const lines = [`Advisors enabled (${stats.advisors.length}):`];
14371
+ for (const s of stats.advisors) {
14372
+ const ctx =
14373
+ s.contextWindow > 0
14374
+ ? `${s.contextTokens.toLocaleString()} / ${s.contextWindow.toLocaleString()} (${Math.round((s.contextTokens / s.contextWindow) * 100)}%)`
14375
+ : `${s.contextTokens.toLocaleString()}`;
14376
+ lines.push(` • ${s.name} (${s.model.provider}/${s.model.id}) — context ${ctx} tokens, $${s.cost.toFixed(4)}`);
14377
+ }
14378
+ lines.push(
14379
+ `Totals: ${stats.tokens.input.toLocaleString()} input, ${stats.tokens.output.toLocaleString()} output, $${stats.cost.toFixed(4)}.`,
14380
+ );
14381
+ return lines.join("\n");
13946
14382
  }
13947
14383
 
13948
14384
  /**
@@ -13986,18 +14422,21 @@ export class AgentSession {
13986
14422
  * {@link formatSessionAsText}. Returns null when no advisor is active.
13987
14423
  */
13988
14424
  formatAdvisorHistoryAsText(options?: { compact?: boolean }): string | null {
13989
- const advisor = this.#advisorAgent;
13990
- if (!advisor) return null;
13991
- if (options?.compact) {
13992
- return formatSessionHistoryMarkdown(advisor.state.messages);
13993
- }
13994
- return formatSessionDumpText({
13995
- messages: advisor.state.messages,
13996
- systemPrompt: advisor.state.systemPrompt,
13997
- model: advisor.state.model,
13998
- thinkingLevel: advisor.state.thinkingLevel,
13999
- tools: advisor.state.tools,
14000
- });
14425
+ if (this.#advisors.length === 0) return null;
14426
+ const dump = (a: ActiveAdvisor): string =>
14427
+ options?.compact
14428
+ ? formatSessionHistoryMarkdown(a.agent.state.messages)
14429
+ : formatSessionDumpText({
14430
+ messages: a.agent.state.messages,
14431
+ systemPrompt: a.agent.state.systemPrompt,
14432
+ model: a.agent.state.model,
14433
+ thinkingLevel: a.agent.state.thinkingLevel,
14434
+ tools: a.agent.state.tools,
14435
+ });
14436
+ if (this.#advisors.length === 1) return dump(this.#advisors[0]);
14437
+ return this.#advisors
14438
+ .map(a => `### Advisor: ${a.name} (${a.agent.state.model.provider}/${a.agent.state.model.id})\n\n${dump(a)}`)
14439
+ .join("\n\n");
14001
14440
  }
14002
14441
 
14003
14442
  // =========================================================================