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

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 (164) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/dist/cli.js +3682 -3615
  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/cli/update-cli.d.ts +15 -0
  10. package/dist/types/collab/guest.d.ts +29 -0
  11. package/dist/types/collab/replication-shrink.d.ts +39 -0
  12. package/dist/types/config/provider-globals.d.ts +7 -0
  13. package/dist/types/config/settings-schema.d.ts +81 -0
  14. package/dist/types/debug/log-viewer.d.ts +1 -0
  15. package/dist/types/debug/raw-sse.d.ts +1 -0
  16. package/dist/types/edit/hashline/diff.d.ts +0 -11
  17. package/dist/types/extensibility/tool-event-input.d.ts +7 -0
  18. package/dist/types/extensibility/utils.d.ts +12 -0
  19. package/dist/types/mcp/transports/index.d.ts +1 -0
  20. package/dist/types/mcp/transports/sse.d.ts +20 -0
  21. package/dist/types/memories/index.d.ts +20 -1
  22. package/dist/types/modes/components/advisor-config.d.ts +59 -0
  23. package/dist/types/modes/components/index.d.ts +1 -0
  24. package/dist/types/modes/components/model-selector.d.ts +9 -1
  25. package/dist/types/modes/components/settings-selector.d.ts +1 -0
  26. package/dist/types/modes/components/status-line/component.d.ts +35 -1
  27. package/dist/types/modes/components/status-line/types.d.ts +13 -1
  28. package/dist/types/modes/controllers/command-controller.d.ts +3 -3
  29. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  30. package/dist/types/modes/interactive-mode.d.ts +11 -4
  31. package/dist/types/modes/skill-command.d.ts +32 -0
  32. package/dist/types/modes/types.d.ts +9 -3
  33. package/dist/types/session/agent-session.d.ts +58 -10
  34. package/dist/types/session/indexed-session-storage.d.ts +7 -1
  35. package/dist/types/session/messages.d.ts +26 -0
  36. package/dist/types/session/messages.test.d.ts +1 -0
  37. package/dist/types/session/session-entries.d.ts +31 -3
  38. package/dist/types/session/session-history-format.d.ts +6 -0
  39. package/dist/types/session/session-loader.d.ts +9 -1
  40. package/dist/types/session/session-manager.d.ts +8 -7
  41. package/dist/types/session/session-storage.d.ts +11 -0
  42. package/dist/types/session/session-title-slot.d.ts +19 -0
  43. package/dist/types/ssh/connection-manager.d.ts +47 -0
  44. package/dist/types/ssh/utils.d.ts +16 -0
  45. package/dist/types/task/executor.d.ts +3 -16
  46. package/dist/types/task/render.d.ts +0 -5
  47. package/dist/types/task/renderer.d.ts +13 -0
  48. package/dist/types/task/types.d.ts +16 -0
  49. package/dist/types/task/yield-assembly.d.ts +28 -0
  50. package/dist/types/tiny/text.d.ts +8 -0
  51. package/dist/types/tools/render-utils.d.ts +2 -0
  52. package/dist/types/tools/review.d.ts +6 -4
  53. package/dist/types/tools/ssh.d.ts +1 -1
  54. package/dist/types/tools/todo.d.ts +6 -0
  55. package/dist/types/tools/yield.d.ts +8 -3
  56. package/dist/types/utils/thinking-display.d.ts +4 -0
  57. package/package.json +12 -12
  58. package/src/advisor/__tests__/advisor.test.ts +242 -10
  59. package/src/advisor/__tests__/config.test.ts +173 -0
  60. package/src/advisor/advise-tool.ts +11 -6
  61. package/src/advisor/config.ts +256 -0
  62. package/src/advisor/index.ts +1 -0
  63. package/src/advisor/runtime.ts +12 -2
  64. package/src/advisor/transcript-recorder.ts +25 -2
  65. package/src/advisor/watchdog.ts +57 -31
  66. package/src/autolearn/controller.ts +13 -22
  67. package/src/autoresearch/index.ts +7 -2
  68. package/src/cli/gc-cli.ts +17 -10
  69. package/src/cli/update-cli.ts +254 -0
  70. package/src/collab/guest.ts +43 -7
  71. package/src/collab/host.ts +13 -8
  72. package/src/collab/replication-shrink.ts +111 -0
  73. package/src/config/model-registry.ts +80 -18
  74. package/src/config/provider-globals.ts +25 -0
  75. package/src/config/settings-schema.ts +77 -0
  76. package/src/debug/index.ts +32 -7
  77. package/src/debug/log-viewer.ts +111 -53
  78. package/src/debug/raw-sse.ts +68 -48
  79. package/src/discovery/codex.ts +13 -5
  80. package/src/edit/hashline/diff.ts +57 -4
  81. package/src/eval/__tests__/julia-prelude.test.ts +2 -2
  82. package/src/eval/js/shared/local-module-loader.ts +23 -1
  83. package/src/export/html/template.js +13 -7
  84. package/src/extensibility/extensions/loader.ts +5 -3
  85. package/src/extensibility/extensions/wrapper.ts +9 -3
  86. package/src/extensibility/hooks/loader.ts +3 -3
  87. package/src/extensibility/hooks/tool-wrapper.ts +13 -4
  88. package/src/extensibility/plugins/manager.ts +2 -1
  89. package/src/extensibility/tool-event-input.ts +23 -0
  90. package/src/extensibility/utils.ts +74 -0
  91. package/src/internal-urls/docs-index.generated.txt +1 -1
  92. package/src/mcp/client.ts +3 -1
  93. package/src/mcp/manager.ts +12 -5
  94. package/src/mcp/transports/index.ts +1 -0
  95. package/src/mcp/transports/sse.ts +377 -0
  96. package/src/memories/index.ts +130 -15
  97. package/src/memory-backend/local-backend.ts +5 -3
  98. package/src/modes/components/advisor-config.ts +555 -0
  99. package/src/modes/components/advisor-message.ts +9 -2
  100. package/src/modes/components/agent-hub.ts +9 -4
  101. package/src/modes/components/index.ts +2 -0
  102. package/src/modes/components/model-selector.ts +79 -48
  103. package/src/modes/components/settings-selector.ts +1 -0
  104. package/src/modes/components/status-line/component.ts +150 -5
  105. package/src/modes/components/status-line/segments.ts +46 -21
  106. package/src/modes/components/status-line/types.ts +13 -1
  107. package/src/modes/components/tool-execution.ts +47 -6
  108. package/src/modes/controllers/command-controller.ts +27 -36
  109. package/src/modes/controllers/event-controller.ts +113 -1
  110. package/src/modes/controllers/extension-ui-controller.ts +1 -1
  111. package/src/modes/controllers/input-controller.ts +61 -61
  112. package/src/modes/controllers/selector-controller.ts +100 -9
  113. package/src/modes/interactive-mode.ts +74 -25
  114. package/src/modes/skill-command.ts +116 -0
  115. package/src/modes/types.ts +9 -3
  116. package/src/modes/utils/ui-helpers.ts +41 -23
  117. package/src/prompts/agents/reviewer.md +11 -10
  118. package/src/prompts/goals/goal-todo-context.md +12 -0
  119. package/src/prompts/review-custom-request.md +1 -2
  120. package/src/prompts/review-request.md +1 -2
  121. package/src/prompts/system/interrupted-thinking.md +7 -0
  122. package/src/prompts/system/recap-user.md +9 -0
  123. package/src/prompts/system/subagent-system-prompt.md +8 -5
  124. package/src/prompts/system/subagent-yield-reminder.md +6 -5
  125. package/src/prompts/system/system-prompt.md +0 -1
  126. package/src/prompts/tools/irc.md +2 -2
  127. package/src/prompts/tools/read.md +2 -2
  128. package/src/sdk.ts +28 -24
  129. package/src/session/agent-session.ts +899 -429
  130. package/src/session/indexed-session-storage.ts +86 -13
  131. package/src/session/messages.test.ts +125 -0
  132. package/src/session/messages.ts +172 -9
  133. package/src/session/redis-session-storage.ts +49 -2
  134. package/src/session/session-entries.ts +39 -2
  135. package/src/session/session-history-format.ts +29 -2
  136. package/src/session/session-listing.ts +54 -24
  137. package/src/session/session-loader.ts +66 -3
  138. package/src/session/session-manager.ts +115 -22
  139. package/src/session/session-persistence.ts +95 -1
  140. package/src/session/session-storage.ts +36 -0
  141. package/src/session/session-title-slot.ts +141 -0
  142. package/src/session/sql-session-storage.ts +71 -11
  143. package/src/slash-commands/builtin-registry.ts +23 -24
  144. package/src/ssh/__tests__/connection-manager-args.test.ts +123 -1
  145. package/src/ssh/__tests__/file-transfer-posix-guard.test.ts +55 -18
  146. package/src/ssh/connection-manager.ts +139 -12
  147. package/src/ssh/file-transfer.ts +23 -18
  148. package/src/ssh/ssh-executor.ts +2 -13
  149. package/src/ssh/utils.ts +19 -0
  150. package/src/task/executor.ts +21 -23
  151. package/src/task/render.ts +162 -20
  152. package/src/task/renderer.ts +14 -0
  153. package/src/task/types.ts +17 -0
  154. package/src/task/yield-assembly.ts +207 -0
  155. package/src/tiny/text.ts +23 -0
  156. package/src/tools/ask.ts +55 -4
  157. package/src/tools/render-utils.ts +2 -0
  158. package/src/tools/renderers.ts +8 -2
  159. package/src/tools/review.ts +17 -7
  160. package/src/tools/ssh.ts +8 -4
  161. package/src/tools/todo.ts +17 -1
  162. package/src/tools/yield.ts +140 -31
  163. package/src/utils/thinking-display.ts +15 -0
  164. package/src/prompts/system/autolearn-nudge.md +0 -5
@@ -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";
@@ -227,11 +231,13 @@ import { containsWorkflow, WORKFLOW_NOTICE } from "../modes/workflow";
227
231
  import { createPlanReadMatcher } from "../plan-mode/plan-protection";
228
232
  import type { PlanModeState } from "../plan-mode/state";
229
233
  import advisorSystemPrompt from "../prompts/advisor/system.md" with { type: "text" };
234
+ import goalTodoContextPrompt from "../prompts/goals/goal-todo-context.md" with { type: "text" };
230
235
  import autoContinuePrompt from "../prompts/system/auto-continue.md" with { type: "text" };
231
236
  import eagerTaskPrompt from "../prompts/system/eager-task.md" with { type: "text" };
232
237
  import eagerTodoPrompt from "../prompts/system/eager-todo.md" with { type: "text" };
233
238
  import emptyStopRetryTemplate from "../prompts/system/empty-stop-retry.md" with { type: "text" };
234
239
  import geminiToolReminderTemplate from "../prompts/system/gemini-tool-call-reminder.md" with { type: "text" };
240
+ import interruptedThinkingTemplate from "../prompts/system/interrupted-thinking.md" with { type: "text" };
235
241
  import ircAutoReplyTemplate from "../prompts/system/irc-autoreply.md" with { type: "text" };
236
242
  import ircIncomingTemplate from "../prompts/system/irc-incoming.md" with { type: "text" };
237
243
  import planModeActivePrompt from "../prompts/system/plan-mode-active.md" with { type: "text" };
@@ -261,6 +267,7 @@ import {
261
267
  shouldDisableReasoning,
262
268
  toReasoningEffort,
263
269
  } from "../thinking";
270
+ import { formatTitleConversationContext, type TitleConversationTurn } from "../tiny/text";
264
271
  import { shutdownTinyTitleClient } from "../tiny/title-client";
265
272
  import { countToolsForAutoDiscovery, resolveEffectiveToolDiscoveryMode } from "../tool-discovery/mode";
266
273
  import {
@@ -288,6 +295,7 @@ import { resolveFileDisplayMode } from "../utils/file-display-mode";
288
295
  import { extractFileMentions, generateFileMentionMessages } from "../utils/file-mentions";
289
296
  import { normalizeModelContextImages } from "../utils/image-loading";
290
297
  import { describeAttachedImagesForTextModel } from "../utils/image-vision-fallback";
298
+ import { generateSessionTitle } from "../utils/title-generator";
291
299
  import { buildNamedToolChoice, isToolChoiceActive } from "../utils/tool-choice";
292
300
  import type { AuthStorage } from "./auth-storage";
293
301
  import type { ClientBridge, ClientBridgePermissionOption, ClientBridgePermissionOutcome } from "./client-bridge";
@@ -303,6 +311,10 @@ import {
303
311
  type BashExecutionMessage,
304
312
  type CustomMessage,
305
313
  convertToLlm,
314
+ demoteInterruptedThinking,
315
+ INTERRUPTED_THINKING_MESSAGE_TYPE,
316
+ type InterruptedThinkingDetails,
317
+ isUserInterruptAbort,
306
318
  type PythonExecutionMessage,
307
319
  readQueueChipText,
308
320
  SILENT_ABORT_MARKER,
@@ -599,20 +611,29 @@ export interface AgentSessionConfig {
599
611
  */
600
612
  providerSessionId?: string;
601
613
  /**
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.
614
+ * Full advisor toolset, pre-built in `createAgentSession` against a distinct,
615
+ * advisor-scoped `ToolSession` (its own `-advisor` session/agent id) so the
616
+ * advisor's tool state stays isolated from the primary. The advisor is a full
617
+ * agent; its config `tools` selects a subset (default read/grep/glob). Undefined
618
+ * when the advisor is disabled.
606
619
  */
607
- advisorReadOnlyTools?: AgentTool[];
620
+ advisorTools?: AgentTool[];
608
621
  /** Preloaded watchdog prompt content for the advisor. */
609
622
  advisorWatchdogPrompt?: string;
623
+ /** Preloaded YAML top-level `instructions` shared baseline, kept separate from
624
+ * `advisorWatchdogPrompt` so `/advisor configure` can swap it live. */
625
+ advisorSharedInstructions?: string;
610
626
  /**
611
627
  * Preloaded project context files (AGENTS.md, etc.) rendered as a system-prompt
612
628
  * block for the advisor — the same standing instructions the primary agent
613
629
  * receives, so the reviewer holds the agent to them.
614
630
  */
615
631
  advisorContextPrompt?: string;
632
+ /**
633
+ * Advisors discovered from `WATCHDOG.yml`. Empty/undefined runs a single
634
+ * legacy advisor on the `advisor` role (byte-for-byte the pre-config path).
635
+ */
636
+ advisorConfigs?: AdvisorConfig[];
616
637
  /**
617
638
  * Strip tool descriptions from provider-bound tool specs on side requests
618
639
  * (handoff). Must match the session-start value used to build the system
@@ -717,6 +738,7 @@ export interface SessionStats {
717
738
  tokens: {
718
739
  input: number;
719
740
  output: number;
741
+ reasoning: number;
720
742
  cacheRead: number;
721
743
  cacheWrite: number;
722
744
  total: number;
@@ -735,6 +757,7 @@ export interface AdvisorStats {
735
757
  tokens: {
736
758
  input: number;
737
759
  output: number;
760
+ reasoning: number;
738
761
  cacheRead: number;
739
762
  cacheWrite: number;
740
763
  total: number;
@@ -745,6 +768,43 @@ export interface AdvisorStats {
745
768
  assistant: number;
746
769
  total: number;
747
770
  };
771
+ /** Per-advisor breakdown; one entry per active advisor (single-advisor sessions have one). */
772
+ advisors: PerAdvisorStat[];
773
+ }
774
+
775
+ /** One advisor's slice of {@link AdvisorStats}, surfaced for the multi-advisor status panel. */
776
+ export interface PerAdvisorStat {
777
+ name: string;
778
+ model: Model;
779
+ contextWindow: number;
780
+ contextTokens: number;
781
+ tokens: AdvisorStats["tokens"];
782
+ cost: number;
783
+ messages: AdvisorStats["messages"];
784
+ }
785
+
786
+ /**
787
+ * One live advisor instance: its own agent/runtime/tools/recorder plus a
788
+ * per-advisor emission guard and identity. The session holds an array of these;
789
+ * primary-scoped state (turn counters, interrupt latches, the shared yield
790
+ * channel) stays on the session.
791
+ */
792
+ interface ActiveAdvisor {
793
+ /** Display name from config ("default" for the legacy no-YAML advisor). */
794
+ name: string;
795
+ /** Slug for the transcript filename/session id; "" → `__advisor.jsonl`. */
796
+ slug: string;
797
+ agent: Agent;
798
+ runtime: AdvisorRuntime;
799
+ adviseTool: AdviseTool;
800
+ emissionGuard: AdvisorEmissionGuard;
801
+ recorder: AdvisorTranscriptRecorder;
802
+ /** Latest recorder close, awaited by dispose() so the final turn lands on disk. */
803
+ recorderClosed: Promise<void>;
804
+ /** Unsubscribe for the advisor agent's event stream feeding the recorder. */
805
+ agentUnsubscribe?: () => void;
806
+ model: Model;
807
+ thinkingLevel: ThinkingLevel;
748
808
  }
749
809
 
750
810
  export interface FreshSessionResult {
@@ -1189,6 +1249,60 @@ type MessageEndPersistenceSlot = {
1189
1249
  release: () => void;
1190
1250
  };
1191
1251
 
1252
+ const REPLAN_TITLE_CONTEXT_TURN_LIMIT = 6;
1253
+
1254
+ type SessionTitleSource = "auto" | "user";
1255
+ type SessionNameTrigger = "replan";
1256
+ type SetSessionNameWithTrigger = (
1257
+ name: string,
1258
+ source?: SessionTitleSource,
1259
+ trigger?: SessionNameTrigger,
1260
+ ) => Promise<boolean>;
1261
+
1262
+ function isRecord(value: unknown): value is Record<string, unknown> {
1263
+ return value !== null && typeof value === "object" && !Array.isArray(value);
1264
+ }
1265
+
1266
+ function textFromContent(content: unknown): string {
1267
+ if (typeof content === "string") return content.trim();
1268
+ if (!Array.isArray(content)) return "";
1269
+ const parts: string[] = [];
1270
+ for (const block of content) {
1271
+ if (!isRecord(block) || block.type !== "text" || typeof block.text !== "string") continue;
1272
+ const text = block.text.trim();
1273
+ if (text) parts.push(text);
1274
+ }
1275
+ return parts.join("\n\n");
1276
+ }
1277
+
1278
+ function thinkingFromContent(content: unknown): string {
1279
+ if (!Array.isArray(content)) return "";
1280
+ const parts: string[] = [];
1281
+ for (const block of content) {
1282
+ if (!isRecord(block) || block.type !== "thinking" || typeof block.thinking !== "string") continue;
1283
+ const thinking = block.thinking.trim();
1284
+ if (thinking) parts.push(thinking);
1285
+ }
1286
+ return parts.join("\n\n");
1287
+ }
1288
+
1289
+ function toolCallOpFromMessage(message: AgentMessage, toolCallId: string): string | undefined {
1290
+ if (message.role !== "assistant" || !Array.isArray(message.content)) return undefined;
1291
+ for (const block of message.content) {
1292
+ if (!isRecord(block) || block.type !== "toolCall" || block.id !== toolCallId) continue;
1293
+ return isRecord(block.arguments) ? getStringProperty(block.arguments, "op") : undefined;
1294
+ }
1295
+ return undefined;
1296
+ }
1297
+
1298
+ function titleConversationTurnFromMessage(message: AgentMessage): TitleConversationTurn | undefined {
1299
+ if (message.role !== "user" && message.role !== "assistant") return undefined;
1300
+ const text = textFromContent(message.content);
1301
+ const thinking = message.role === "assistant" ? thinkingFromContent(message.content) : undefined;
1302
+ if (!text && !thinking) return undefined;
1303
+ return { role: message.role, ...(text ? { text } : {}), ...(thinking ? { thinking } : {}) };
1304
+ }
1305
+
1192
1306
  export class AgentSession {
1193
1307
  readonly agent: Agent;
1194
1308
  readonly sessionManager: SessionManager;
@@ -1229,29 +1343,21 @@ export class AgentSession {
1229
1343
  #advisorAutoResumeSuppressed = false;
1230
1344
  #advisorPrimaryTurnsCompleted = 0;
1231
1345
  #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
1346
  #planModeState: PlanModeState | undefined;
1238
1347
  #goalModeState: GoalModeState | undefined;
1239
1348
  #goalRuntime: GoalRuntime;
1240
- #advisorRuntime?: AdvisorRuntime;
1241
1349
  #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[];
1350
+ #advisorTools?: AgentTool[];
1246
1351
  #advisorWatchdogPrompt?: string;
1352
+ #advisorSharedInstructions?: string;
1247
1353
  #advisorContextPrompt?: string;
1248
1354
  #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. */
1355
+ /** Live advisors. Empty when no advisor is active. */
1356
+ #advisors: ActiveAdvisor[] = [];
1357
+ /** Configured advisor roster from WATCHDOG.yml; undefined/empty → single legacy advisor. */
1358
+ #advisorConfigs?: AdvisorConfig[];
1359
+ /** Aggregate of the most recent stop's recorder closes; awaited by dispose() and
1360
+ * used as the open barrier for the next build so two writers never share a file. */
1255
1361
  #advisorRecorderClosed: Promise<void> = Promise.resolve();
1256
1362
  #goalTurnCounter = 0;
1257
1363
  #planReferenceSent = false;
@@ -1290,6 +1396,7 @@ export class AgentSession {
1290
1396
  */
1291
1397
  #todoReminderAwaitingProgress = false;
1292
1398
  #todoPhases: TodoPhase[] = [];
1399
+ #replanTitleRefreshInFlight: Promise<void> | undefined = undefined;
1293
1400
  #toolChoiceQueue = new ToolChoiceQueue();
1294
1401
 
1295
1402
  // Bash execution state
@@ -1669,9 +1776,11 @@ export class AgentSession {
1669
1776
  // toggle scopes priority to Fireworks alone, without mutating the shared
1670
1777
  // session `serviceTier` that drives `/fast` and OpenAI/Anthropic priority.
1671
1778
  this.agent.serviceTierResolver = model => this.#effectiveServiceTier(model);
1672
- this.#advisorReadOnlyTools = config.advisorReadOnlyTools;
1779
+ this.#advisorTools = config.advisorTools;
1673
1780
  this.#advisorWatchdogPrompt = config.advisorWatchdogPrompt;
1781
+ this.#advisorSharedInstructions = config.advisorSharedInstructions;
1674
1782
  this.#advisorContextPrompt = config.advisorContextPrompt;
1783
+ this.#advisorConfigs = config.advisorConfigs;
1675
1784
  this.#pruneToolDescriptions = config.pruneToolDescriptions === true;
1676
1785
  this.#validateRetryFallbackChains();
1677
1786
  this.#toolRegistry = config.toolRegistry ?? new Map();
@@ -1716,12 +1825,15 @@ export class AgentSession {
1716
1825
  await this.#applyRewind(rewindReport, messages);
1717
1826
  }
1718
1827
  this.#advisorPrimaryTurnsCompleted++;
1719
- if (this.#advisorRuntime && !this.#advisorRuntime.disposed) {
1720
- this.#advisorRuntime.onTurnEnd(messages);
1828
+ if (this.#advisors.length > 0) {
1829
+ for (const a of this.#advisors) {
1830
+ if (!a.runtime.disposed) a.runtime.onTurnEnd(messages);
1831
+ }
1721
1832
  const syncBacklog = this.settings.get("advisor.syncBacklog");
1722
1833
  if (syncBacklog !== "off") {
1723
1834
  const threshold = parseInt(syncBacklog, 10);
1724
- await this.#advisorRuntime.waitForCatchup(30000, threshold, signal);
1835
+ // Parallel so the 30s catch-up budget is shared across advisors, not summed.
1836
+ await Promise.all(this.#advisors.map(a => a.runtime.waitForCatchup(30000, threshold, signal)));
1725
1837
  }
1726
1838
  }
1727
1839
  await this.#maintainContextMidRun(messages, signal, context);
@@ -1891,12 +2003,14 @@ export class AgentSession {
1891
2003
  // Mute the recorder across the re-prime: AdvisorRuntime.reset() aborts the advisor
1892
2004
  // loop, and that abort can emit an `aborted` message_end we must not attribute to
1893
2005
  // 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();
2006
+ for (const a of this.#advisors) {
2007
+ a.agentUnsubscribe?.();
2008
+ a.agentUnsubscribe = undefined;
2009
+ a.runtime.reset();
2010
+ a.adviseTool.resetDeliveredNotes();
2011
+ a.emissionGuard.reset();
2012
+ this.#attachAdvisorRecorderFeed(a);
2013
+ }
1900
2014
  this.#advisorPrimaryTurnsCompleted = 0;
1901
2015
  this.#advisorInterruptImmuneTurnStart = undefined;
1902
2016
  this.#advisorAutoResumeSuppressed = false;
@@ -1909,261 +2023,298 @@ export class AgentSession {
1909
2023
 
1910
2024
  #buildAdvisorRuntime(seedToCurrent = false): boolean {
1911
2025
  if (this.#isDisposed) return false;
1912
- if (this.#advisorRuntime) return true;
2026
+ if (this.#advisors.length > 0) return true;
1913
2027
  if (!this.#advisorEnabled) return false;
1914
2028
  if (this.#agentKind !== "main" && !this.settings.get("advisor.subagents")) return false;
1915
2029
 
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;
2030
+ // The lone implicit "default" advisor (slug "") reproduces the legacy
2031
+ // single-advisor path: the `advisor` role model + `__advisor.jsonl`.
2032
+ const legacy = !this.#advisorConfigs?.length;
2033
+ const roster: AdvisorConfig[] = legacy ? [{ name: "default" }] : this.#advisorConfigs!;
2001
2034
 
2002
2035
  // Advisor service tier (`serviceTierAdvisor`): "none" (default) runs the
2003
2036
  // advisor on standard processing; "inherit" tracks the session's live tier
2004
2037
  // 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.
2038
+ // a concrete value pins the advisor to that tier. One value for all advisors.
2006
2039
  const advisorTierSetting = this.settings.get("serviceTierAdvisor");
2007
2040
  const advisorServiceTier =
2008
2041
  advisorTierSetting === "inherit" ? undefined : resolveServiceTierSetting(advisorTierSetting, undefined);
2009
2042
  const advisorServiceTierResolver =
2010
2043
  advisorTierSetting === "inherit" ? (model: Model) => this.#effectiveServiceTier(model) : undefined;
2011
2044
 
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,
2045
+ const usedSlugs = new Set<string>();
2046
+ for (const config of roster) {
2047
+ let slug = legacy ? "" : slugifyAdvisorName(config.name);
2048
+ if (slug) {
2049
+ let candidate = slug;
2050
+ let n = 2;
2051
+ while (usedSlugs.has(candidate)) candidate = `${slug}-${n++}`;
2052
+ slug = candidate;
2053
+ usedSlugs.add(slug);
2054
+ }
2055
+
2056
+ // Resolve the advisor's model: an explicit `model` override wins; else the
2057
+ // `advisor` role chain. A model that fails to resolve skips just this advisor.
2058
+ let model: Model | undefined;
2059
+ let thinkingLevel: ThinkingLevel | undefined;
2060
+ if (config.model) {
2061
+ const resolved = resolveModelOverride([config.model], this.#modelRegistry, this.settings);
2062
+ model = resolved.model;
2063
+ thinkingLevel = resolved.thinkingLevel;
2064
+ if (!model) {
2065
+ this.emitNotice("warning", `Advisor "${config.name}": no model matched "${config.model}"`, "advisor");
2066
+ continue;
2027
2067
  }
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;
2068
+ } else {
2069
+ const sel = resolveAdvisorRoleSelection(
2070
+ this.settings,
2071
+ this.#modelRegistry.getAvailable(),
2072
+ this.#modelRegistry,
2073
+ );
2074
+ if (!sel) {
2075
+ logger.debug("advisor enabled but no model assigned to the 'advisor' role; advisor inactive", {
2076
+ advisor: config.name,
2077
+ });
2078
+ continue;
2088
2079
  }
2089
- appendOnlyContext.resetSyncCursor();
2090
- advisorAgent.state.error = undefined;
2091
- },
2092
- state: advisorAgent.state,
2093
- };
2080
+ model = sel.model;
2081
+ thinkingLevel = sel.thinkingLevel;
2082
+ }
2083
+ const advisorModel = model;
2084
+ const advisorName = config.name;
2085
+ const advisorThinkingLevel = thinkingLevel ?? ThinkingLevel.Medium;
2086
+
2087
+ const emissionGuard = new AdvisorEmissionGuard();
2088
+ const adviseTool = new AdviseTool((note, severity) => this.#routeAdvice(advisorRef, note, severity));
2089
+
2090
+ // `#advisorWatchdogPrompt` already carries WATCHDOG.md + YAML shared
2091
+ // instructions; `config.instructions` adds this advisor's specialization.
2092
+ const systemPrompt = [advisorSystemPrompt];
2093
+ if (this.#advisorContextPrompt) systemPrompt.push(this.#advisorContextPrompt);
2094
+ if (this.#advisorWatchdogPrompt) systemPrompt.push(this.#advisorWatchdogPrompt);
2095
+ if (this.#advisorSharedInstructions) systemPrompt.push(this.#advisorSharedInstructions);
2096
+ if (config.instructions?.trim()) systemPrompt.push(config.instructions.trim());
2097
+
2098
+ const names = config.tools?.length ? new Set(config.tools) : ADVISOR_DEFAULT_TOOL_NAMES;
2099
+ const tools = (this.#advisorTools ?? []).filter(t => names.has(t.name));
2100
+
2101
+ const advisorSessionId = this.#advisorSessionId(slug);
2102
+ const appendOnlyContext = new AppendOnlyContextManager();
2103
+
2104
+ // Thread the primary's telemetry into the advisor loop so the advisor
2105
+ // model's GenAI spans + usage/cost hooks fire stamped with the advisor's
2106
+ // own identity. `conversationId` is cleared so the advisor loop falls back
2107
+ // to its own session id; undefined telemetry stays undefined.
2108
+ const advisorTelemetry = this.agent.telemetry
2109
+ ? {
2110
+ ...this.agent.telemetry,
2111
+ agent: {
2112
+ id: advisorSessionId,
2113
+ name: slug ? `${MODEL_ROLES.advisor.name}: ${advisorName}` : MODEL_ROLES.advisor.name,
2114
+ description: formatModelString(advisorModel),
2115
+ },
2116
+ conversationId: undefined,
2117
+ }
2118
+ : undefined;
2119
+ // Mirror the SDK's provider-shaping options (streamFn/onPayload/...,
2120
+ // providerSessionState, promptCacheKey, transformProviderContext) so each
2121
+ // advisor's requests cache, route, and obfuscate like the main turn.
2122
+ const advisorAgent = new Agent({
2123
+ initialState: {
2124
+ systemPrompt,
2125
+ model: advisorModel,
2126
+ thinkingLevel: toReasoningEffort(advisorThinkingLevel),
2127
+ tools: [adviseTool, ...tools],
2128
+ },
2129
+ appendOnlyContext,
2130
+ sessionId: advisorSessionId,
2131
+ promptCacheKey: advisorSessionId,
2132
+ providerSessionState: this.#providerSessionState,
2133
+ getApiKey: requestModel => this.#modelRegistry.resolver(requestModel, advisorSessionId),
2134
+ streamFn: this.#advisorStreamFn,
2135
+ onPayload: this.#onPayload,
2136
+ onResponse: this.#onResponse,
2137
+ onSseEvent: this.#onSseEvent,
2138
+ transformProviderContext: this.#transformProviderContext,
2139
+ intentTracing: false,
2140
+ telemetry: advisorTelemetry,
2141
+ serviceTier: advisorServiceTier,
2142
+ serviceTierResolver: advisorServiceTierResolver,
2143
+ });
2144
+ advisorAgent.setDisableReasoning(shouldDisableReasoning(advisorThinkingLevel));
2145
+
2146
+ const advisorAgentFacade: AdvisorAgent = {
2147
+ prompt: input => advisorAgent.prompt(input),
2148
+ abort: reason => advisorAgent.abort(reason),
2149
+ reset: () => {
2150
+ advisorAgent.reset();
2151
+ appendOnlyContext.log.clear();
2152
+ },
2153
+ rollbackTo: count => {
2154
+ // Drop the failed user batch + synthetic assistant-error turn
2155
+ // `Agent.#runLoop` appended for a turn ending in `stopReason: "error"`.
2156
+ const messages = advisorAgent.state.messages;
2157
+ if (count < messages.length) {
2158
+ messages.length = count;
2159
+ }
2160
+ appendOnlyContext.resetSyncCursor();
2161
+ advisorAgent.state.error = undefined;
2162
+ },
2163
+ state: advisorAgent.state,
2164
+ };
2094
2165
 
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,
2166
+ // Persist this advisor's turns to `<session>/__advisor[.<slug>].jsonl`
2167
+ // (resolved lazily so it follows session switches) for stats attribution
2168
+ // and Agent Hub observability, without registering it as a peer.
2169
+ const recorder = new AdvisorTranscriptRecorder(
2170
+ () => this.sessionManager.getSessionFile(),
2171
+ () => this.sessionManager.getCwd(),
2172
+ advisorTranscriptFilename(slug),
2173
+ // On the advisor on→off→on toggle, wait for the prior recorders' closes
2174
+ // so two SessionManagers never hold the same file at once.
2175
+ this.#advisorRecorderClosed,
2176
+ );
2177
+ const runtime = new AdvisorRuntime(advisorAgentFacade, {
2178
+ snapshotMessages: () => this.agent.state.messages,
2179
+ enqueueAdvice: (note, severity) => this.#routeAdvice(advisorRef, note, severity),
2180
+ maintainContext: incomingTokens => this.#maintainAdvisorContext(advisorRef, incomingTokens),
2181
+ obfuscator: this.#obfuscator,
2182
+ beginAdvisorUpdate: () => advisorRef.emissionGuard.beginUpdate(),
2183
+ notifyFailure: error => {
2184
+ const message = error instanceof Error ? error.message : String(error);
2185
+ this.emitNotice(
2186
+ "warning",
2187
+ `Advisor${slug ? ` "${advisorName}"` : ""} unavailable for ${formatModelString(advisorModel)}: ${message}`,
2188
+ "advisor",
2189
+ );
2190
+ },
2191
+ });
2192
+
2193
+ const advisorRef: ActiveAdvisor = {
2194
+ name: advisorName,
2195
+ slug,
2196
+ agent: advisorAgent,
2197
+ runtime,
2198
+ adviseTool,
2199
+ emissionGuard,
2200
+ recorder,
2201
+ recorderClosed: Promise.resolve(),
2202
+ model: advisorModel,
2203
+ thinkingLevel: advisorThinkingLevel,
2204
+ };
2205
+ this.#attachAdvisorRecorderFeed(advisorRef);
2206
+ if (seedToCurrent) runtime.seedTo(this.agent.state.messages.length);
2207
+ this.#advisors.push(advisorRef);
2208
+ }
2209
+
2210
+ // One shared non-blocking aside channel for all advisors; the build callback
2211
+ // aggregates every advisor's queued nits into one card (each entry already
2212
+ // carries its own `advisor` name).
2213
+ if (this.#advisors.length > 0 && !this.#advisorYieldQueueUnsubscribe) {
2214
+ this.#advisorYieldQueueUnsubscribe = this.yieldQueue.register<AdvisorNote>("advisor", {
2215
+ build: entries =>
2216
+ entries.length === 0
2217
+ ? null
2218
+ : ({
2219
+ role: "custom",
2220
+ customType: "advisor",
2221
+ display: true,
2222
+ attribution: "agent",
2223
+ timestamp: Date.now(),
2224
+ content: formatAdvisorBatchContent(entries),
2225
+ details: { notes: entries } satisfies AdvisorMessageDetails,
2226
+ } satisfies CustomMessage),
2227
+ skipIdleFlush: true,
2228
+ });
2229
+ }
2230
+
2231
+ return this.#advisors.length > 0;
2232
+ }
2233
+
2234
+ /** Provider/session id for an advisor's loop. The slug suffix MUST match the
2235
+ * advisor's transcript filename so stats/telemetry attribute the same advisor. */
2236
+ #advisorSessionId(slug: string): string | undefined {
2237
+ if (!this.sessionId) return undefined;
2238
+ return slug ? `${this.sessionId}-advisor-${slug}` : `${this.sessionId}-advisor`;
2239
+ }
2240
+
2241
+ /**
2242
+ * Route one accepted advice note from `advisor` to the primary. Concern and
2243
+ * blocker interrupt the running agent through the steering channel; once the
2244
+ * loop has yielded, `triggerTurn` resumes it. After a deliberate user interrupt
2245
+ * auto-resume is suppressed while idle/unwinding (the note becomes a preserved
2246
+ * card re-entering on resume); a live-streaming turn is steered in directly. A
2247
+ * plain nit always rides the non-interrupting YieldQueue aside. Suppression by
2248
+ * the per-advisor emission guard drops the note silently — the model still saw
2249
+ * `Recorded.`, so it isn't tempted to rephrase the same note past the dedupe.
2250
+ */
2251
+ #routeAdvice(advisor: ActiveAdvisor, note: string, severity?: AdvisorSeverity): void {
2252
+ if (!advisor.emissionGuard.accept(note)) {
2253
+ logger.debug("advisor advice suppressed by emission guard", { severity, advisor: advisor.name });
2254
+ return;
2255
+ }
2256
+ // The implicit single ("default") advisor stamps no source name, so its
2257
+ // agent-facing `<advisory>` bytes stay identical to the pre-multi-advisor path.
2258
+ const source = advisor.slug ? advisor.name : undefined;
2259
+ const interrupting = isInterruptingSeverity(severity);
2260
+ const channel = resolveAdvisorDeliveryChannel({
2261
+ severity,
2262
+ autoResumeSuppressed: this.#advisorAutoResumeSuppressed,
2263
+ // Key on the live agent-core loop, not session `isStreaming` (which also
2264
+ // counts `#promptInFlightCount` during post-turn unwind). Only a running
2265
+ // loop consumes a steer at its next boundary.
2266
+ streaming: this.agent.state.isStreaming,
2267
+ aborting: this.#abortInProgress,
2268
+ interruptImmuneTurnActive: interrupting && this.#isAdvisorInterruptImmuneTurnActive(),
2142
2269
  });
2270
+ if (channel === "aside") {
2271
+ this.yieldQueue.enqueue("advisor", { note, severity, advisor: source });
2272
+ return;
2273
+ }
2274
+ const notes: AdvisorNote[] = [{ note, severity, advisor: source }];
2275
+ const content = formatAdvisorBatchContent(notes);
2276
+ const details = { notes } satisfies AdvisorMessageDetails;
2277
+ if (channel === "preserve") {
2278
+ this.#preserveAdvisorCard({
2279
+ role: "custom",
2280
+ customType: "advisor",
2281
+ content,
2282
+ display: true,
2283
+ attribution: "agent",
2284
+ details,
2285
+ timestamp: Date.now(),
2286
+ });
2287
+ return;
2288
+ }
2289
+ this.#recordAdvisorInterruptDelivered();
2290
+ void this.sendCustomMessage(
2291
+ { customType: "advisor", content, display: true, attribution: "agent", details },
2292
+ { deliverAs: "steer", triggerTurn: true },
2293
+ ).catch(err => logger.debug("advisor delivery failed", { err: String(err) }));
2294
+ }
2143
2295
 
2144
- return true;
2296
+ /** Re-prime every advisor's transcript view (compaction/shake/rewind) without the
2297
+ * session-level latch reset {@link #resetAdvisorSessionState} performs. */
2298
+ #resetAllAdvisorRuntimes(): void {
2299
+ for (const a of this.#advisors) a.runtime.reset();
2145
2300
  }
2146
2301
 
2147
2302
  #stopAdvisorRuntime(): void {
2148
- // Detach the recorder feed BEFORE aborting the advisor agent: dispose() aborts
2303
+ // Detach each recorder feed BEFORE aborting its advisor agent: dispose() aborts
2149
2304
  // the loop, and an abort emits a final `message_end` we must not enqueue against
2150
2305
  // 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 —
2306
+ const closes: Promise<void>[] = [];
2307
+ for (const a of this.#advisors) {
2308
+ a.agentUnsubscribe?.();
2309
+ a.agentUnsubscribe = undefined;
2310
+ a.runtime.dispose();
2311
+ // Capture each close so dispose()/`/drop` can await the queued open+append+close —
2159
2312
  // 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;
2313
+ a.recorderClosed = a.recorder.close();
2314
+ closes.push(a.recorderClosed);
2165
2315
  }
2166
- this.#advisorAdviseTool = undefined;
2316
+ this.#advisorRecorderClosed = Promise.all(closes).then(() => {});
2317
+ this.#advisors = [];
2167
2318
  this.#advisorYieldQueueUnsubscribe?.();
2168
2319
  this.#advisorYieldQueueUnsubscribe = undefined;
2169
2320
  }
@@ -2171,16 +2322,13 @@ export class AgentSession {
2171
2322
  /** Subscribe the advisor agent's finalized messages into the transcript recorder.
2172
2323
  * Idempotent-by-replacement: callers detach the prior feed first. Kept separate
2173
2324
  * 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);
2325
+ #attachAdvisorRecorderFeed(advisor: ActiveAdvisor): void {
2326
+ advisor.agentUnsubscribe = advisor.agent.subscribe(event => {
2327
+ if (event.type === "message_end") advisor.recorder.record(event.message);
2180
2328
  });
2181
2329
  }
2182
2330
 
2183
- async #promoteAdvisorContextModel(currentModel: Model): Promise<boolean> {
2331
+ async #promoteAdvisorContextModel(advisor: ActiveAdvisor, currentModel: Model): Promise<boolean> {
2184
2332
  const promotionSettings = this.settings.getGroup("contextPromotion");
2185
2333
  if (!promotionSettings.enabled) return false;
2186
2334
  const contextWindow = currentModel.contextWindow ?? 0;
@@ -2188,25 +2336,23 @@ export class AgentSession {
2188
2336
  const targetModel = await this.#resolveContextPromotionTarget(currentModel, contextWindow);
2189
2337
  if (!targetModel) return false;
2190
2338
 
2191
- const advisorSel = resolveAdvisorRoleSelection(
2192
- this.settings,
2193
- this.#modelRegistry.getAvailable(),
2194
- this.#modelRegistry,
2195
- );
2196
- const advisorThinkingLevel = advisorSel?.thinkingLevel ?? ThinkingLevel.Medium;
2197
-
2339
+ // Preserve this advisor's own thinking level (a configured `model:...:high`
2340
+ // keeps its suffix across a promotion); only the model changes.
2341
+ const advisorThinkingLevel = advisor.thinkingLevel;
2198
2342
  try {
2199
- this.#advisorAgent?.setModel(targetModel);
2200
- this.#advisorAgent?.setThinkingLevel(toReasoningEffort(advisorThinkingLevel));
2201
- this.#advisorAgent?.setDisableReasoning(shouldDisableReasoning(advisorThinkingLevel));
2202
- this.#advisorAgent?.appendOnlyContext?.invalidateForModelChange();
2343
+ advisor.agent.setModel(targetModel);
2344
+ advisor.agent.setThinkingLevel(toReasoningEffort(advisorThinkingLevel));
2345
+ advisor.agent.setDisableReasoning(shouldDisableReasoning(advisorThinkingLevel));
2346
+ advisor.agent.appendOnlyContext?.invalidateForModelChange();
2203
2347
  logger.debug("Advisor context promotion switched model on overflow", {
2348
+ advisor: advisor.name,
2204
2349
  from: `${currentModel.provider}/${currentModel.id}`,
2205
2350
  to: `${targetModel.provider}/${targetModel.id}`,
2206
2351
  });
2207
2352
  return true;
2208
2353
  } catch (error) {
2209
2354
  logger.warn("Advisor context promotion failed", {
2355
+ advisor: advisor.name,
2210
2356
  from: `${currentModel.provider}/${currentModel.id}`,
2211
2357
  to: `${targetModel.provider}/${targetModel.id}`,
2212
2358
  error: String(error),
@@ -2215,19 +2361,18 @@ export class AgentSession {
2215
2361
  }
2216
2362
  }
2217
2363
 
2218
- async #maintainAdvisorContext(incomingTokens: number): Promise<boolean> {
2219
- const advisor = this.#advisorAgent;
2220
- if (!advisor) return false;
2364
+ async #maintainAdvisorContext(advisor: ActiveAdvisor, incomingTokens: number): Promise<boolean> {
2365
+ const agent = advisor.agent;
2221
2366
 
2222
2367
  const compactionSettings = this.settings.getGroup("compaction");
2223
2368
  if (compactionSettings.strategy === "off") return false;
2224
2369
  if (!compactionSettings.enabled) return false;
2225
2370
 
2226
- const advisorModel = advisor.state.model;
2371
+ const advisorModel = agent.state.model;
2227
2372
  const contextWindow = advisorModel.contextWindow ?? 0;
2228
2373
  if (contextWindow <= 0) return false;
2229
2374
 
2230
- const messages = advisor.state.messages;
2375
+ const messages = agent.state.messages;
2231
2376
  let contextTokens = incomingTokens;
2232
2377
  for (const message of messages) {
2233
2378
  contextTokens += estimateTokens(message);
@@ -2238,9 +2383,9 @@ export class AgentSession {
2238
2383
  }
2239
2384
 
2240
2385
  // 1. Try promotion first
2241
- if (await this.#promoteAdvisorContextModel(advisorModel)) {
2386
+ if (await this.#promoteAdvisorContextModel(advisor, advisorModel)) {
2242
2387
  // Promotion succeeded, check if new model has enough space
2243
- const newModel = advisor.state.model;
2388
+ const newModel = agent.state.model;
2244
2389
  const newWindow = newModel.contextWindow ?? 0;
2245
2390
  if (newWindow > 0) {
2246
2391
  const stillNeedsCompaction = shouldCompact(contextTokens, newWindow, compactionSettings);
@@ -2262,7 +2407,9 @@ export class AgentSession {
2262
2407
  timestamp,
2263
2408
  summary: message.summary,
2264
2409
  shortSummary: message.shortSummary,
2265
- firstKeptEntryId: (message as any).firstKeptEntryId || `msg-${i + 1}`,
2410
+ firstKeptEntryId:
2411
+ (message as CompactionSummaryMessage & { firstKeptEntryId?: string }).firstKeptEntryId ||
2412
+ `msg-${i + 1}`,
2266
2413
  tokensBefore: message.tokensBefore,
2267
2414
  } satisfies CompactionEntry;
2268
2415
  }
@@ -2276,33 +2423,37 @@ export class AgentSession {
2276
2423
  } satisfies SessionMessageEntry;
2277
2424
  });
2278
2425
 
2279
- const preparation = prepareCompaction(pathEntries, compactionSettings);
2426
+ const availableModels = this.#modelRegistry.getAvailable();
2427
+ const candidates = this.#resolveCompactionModelCandidates(advisorModel, availableModels);
2428
+ if (candidates.length === 0) {
2429
+ // No compaction candidates, fallback to re-prime
2430
+ return true;
2431
+ }
2432
+ const advisorSessionId = this.#advisorSessionId(advisor.slug);
2433
+ const preparation = prepareCompaction(
2434
+ pathEntries,
2435
+ compactionSettings,
2436
+ await this.#runnableCompactionCandidates(candidates, advisorSessionId),
2437
+ );
2280
2438
  if (!preparation) {
2281
2439
  // Cannot prepare compaction, fallback to re-prime
2282
2440
  return true;
2283
2441
  }
2284
2442
 
2285
- const advisorCompactionThinkingLevel: ThinkingLevel | undefined = advisor.state.disableReasoning
2443
+ const advisorCompactionThinkingLevel: ThinkingLevel | undefined = agent.state.disableReasoning
2286
2444
  ? ThinkingLevel.Off
2287
- : advisor.state.thinkingLevel;
2445
+ : agent.state.thinkingLevel;
2288
2446
 
2289
2447
  // Advisor state is in-memory-only, so snapcompact's frame archive has no
2290
2448
  // stable SessionEntry preserveData slot to carry across future advisor
2291
2449
  // maintenance runs. Use an LLM summary even when the primary session is
2292
2450
  // 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
2451
 
2300
2452
  let compactResult: CompactionResult | undefined;
2301
2453
  let lastError: unknown;
2302
- const advisorSessionId = this.sessionId ? `${this.sessionId}-advisor` : undefined;
2303
2454
  // Instrument the advisor's overflow-compaction one-shot like the primary
2304
2455
  // compaction path so the advisor model's maintenance call also emits spans.
2305
- const telemetry = resolveTelemetry(advisor.telemetry, advisorSessionId);
2456
+ const telemetry = resolveTelemetry(agent.telemetry, advisorSessionId);
2306
2457
 
2307
2458
  for (const candidate of candidates) {
2308
2459
  const apiKey = await this.#modelRegistry.getApiKey(candidate, advisorSessionId);
@@ -2319,6 +2470,9 @@ export class AgentSession {
2319
2470
  thinkingLevel: advisorCompactionThinkingLevel,
2320
2471
  convertToLlm: messages => this.#convertToLlmForSideRequest(messages),
2321
2472
  telemetry,
2473
+ tools: agent.state.tools,
2474
+ sessionId: advisorSessionId,
2475
+ promptCacheKey: advisorSessionId,
2322
2476
  },
2323
2477
  );
2324
2478
  break;
@@ -2343,7 +2497,7 @@ export class AgentSession {
2343
2497
  firstKeptEntryId,
2344
2498
  } as CompactionSummaryMessage & { firstKeptEntryId?: string };
2345
2499
 
2346
- advisor.replaceMessages([summaryMessage, ...preparation.recentMessages]);
2500
+ agent.replaceMessages([summaryMessage, ...preparation.recentMessages]);
2347
2501
  return false;
2348
2502
  }
2349
2503
 
@@ -2776,6 +2930,37 @@ export class AgentSession {
2776
2930
  }
2777
2931
  }
2778
2932
 
2933
+ /**
2934
+ * On a user-interrupted (`Esc`) abort, copy the trailing thinking run into a
2935
+ * hidden `display: false` continuity message for the next turn WITHOUT
2936
+ * mutating the assistant message. The original thinking stays on the message
2937
+ * so live render, reload, and Ctrl+L rebuilds keep showing it; `convertToLlm`
2938
+ * strips the run from the provider request (incomplete/unsigned thinking is
2939
+ * rejected on resend) when this continuity message follows the assistant turn.
2940
+ */
2941
+ #demoteInterruptedThinkingOnUserInterrupt(
2942
+ message: AssistantMessage,
2943
+ ): CustomMessage<InterruptedThinkingDetails> | undefined {
2944
+ if (message.stopReason !== "aborted" || !isUserInterruptAbort(message)) return undefined;
2945
+ const demoted = demoteInterruptedThinking(message);
2946
+ if (!demoted) return undefined;
2947
+ const interruptedAt = Date.now();
2948
+ return {
2949
+ role: "custom",
2950
+ customType: INTERRUPTED_THINKING_MESSAGE_TYPE,
2951
+ content: prompt.render(interruptedThinkingTemplate, { reasoning: demoted.reasoning }),
2952
+ display: false,
2953
+ details: {
2954
+ interruptedAt,
2955
+ provider: message.provider,
2956
+ model: message.model,
2957
+ blockCount: demoted.blockCount,
2958
+ },
2959
+ attribution: "agent",
2960
+ timestamp: interruptedAt,
2961
+ };
2962
+ }
2963
+
2779
2964
  async #persistTurnMessagesForMidRunCompaction(context: AgentTurnEndContext | undefined): Promise<boolean> {
2780
2965
  if (!context) return true;
2781
2966
  const turnMessages = [context.message, ...context.toolResults];
@@ -2843,6 +3028,17 @@ export class AgentSession {
2843
3028
  }
2844
3029
  }
2845
3030
 
3031
+ const interruptedThinkingMessage =
3032
+ event.type === "message_end" && event.message.role === "assistant"
3033
+ ? this.#demoteInterruptedThinkingOnUserInterrupt(event.message as AssistantMessage)
3034
+ : undefined;
3035
+ // `message_end` handling is fire-and-forget from agent-core. Make the
3036
+ // hidden continuity turn visible to the next prompt before any awaited
3037
+ // extension delivery or persistence can stall this handler.
3038
+ if (interruptedThinkingMessage) {
3039
+ this.agent.appendMessage(interruptedThinkingMessage);
3040
+ }
3041
+
2846
3042
  const messageEndPersistence =
2847
3043
  event.type === "message_end" ? this.#createMessageEndPersistenceSlot(event.message) : undefined;
2848
3044
 
@@ -2985,6 +3181,15 @@ export class AgentSession {
2985
3181
  } else {
2986
3182
  persistMessageEnd();
2987
3183
  }
3184
+ if (interruptedThinkingMessage) {
3185
+ this.sessionManager.appendCustomMessageEntry(
3186
+ interruptedThinkingMessage.customType,
3187
+ interruptedThinkingMessage.content,
3188
+ interruptedThinkingMessage.display,
3189
+ interruptedThinkingMessage.details,
3190
+ interruptedThinkingMessage.attribution,
3191
+ );
3192
+ }
2988
3193
  // Other message types (bashExecution, compactionSummary, branchSummary) are persisted elsewhere
2989
3194
 
2990
3195
  // Track assistant message for auto-compaction (checked on agent_end)
@@ -3041,9 +3246,10 @@ export class AgentSession {
3041
3246
  }
3042
3247
  }
3043
3248
  if (event.message.role === "toolResult") {
3044
- const { toolName, details, isError, content } = event.message as {
3249
+ const { toolName, toolCallId, details, isError, content } = event.message as {
3250
+ toolCallId?: string;
3045
3251
  toolName?: string;
3046
- details?: { path?: string; phases?: TodoPhase[]; report?: string; startedAt?: string };
3252
+ details?: { op?: string; path?: string; phases?: TodoPhase[]; report?: string; startedAt?: string };
3047
3253
  isError?: boolean;
3048
3254
  content?: Array<TextContent | ImageContent>;
3049
3255
  };
@@ -3057,6 +3263,9 @@ export class AgentSession {
3057
3263
  }
3058
3264
  if (toolName === "todo" && !isError && Array.isArray(details?.phases)) {
3059
3265
  this.setTodoPhases(details.phases);
3266
+ if (this.#isTodoInitResult(details, toolCallId)) {
3267
+ this.#scheduleReplanTitleRefresh();
3268
+ }
3060
3269
  }
3061
3270
  if (toolName === "todo" && isError) {
3062
3271
  const errorText = content?.find(part => part.type === "text")?.text;
@@ -6260,16 +6469,46 @@ export class AgentSession {
6260
6469
  #buildGoalModeMessage(): CustomMessage | null {
6261
6470
  const content = this.#goalRuntime.buildActivePrompt();
6262
6471
  if (!content) return null;
6472
+ const todoContext = this.#buildGoalTodoContext();
6263
6473
  return {
6264
6474
  role: "custom",
6265
6475
  customType: "goal-mode-context",
6266
- content,
6476
+ content: todoContext ? `${content}\n\n${todoContext}` : content,
6267
6477
  display: false,
6268
6478
  attribution: "agent",
6269
6479
  timestamp: Date.now(),
6270
6480
  };
6271
6481
  }
6272
6482
 
6483
+ #buildGoalTodoContext(): string | undefined {
6484
+ if (!this.settings.get("todo.enabled")) return undefined;
6485
+ const phases = this.getTodoPhases().filter(phase => phase.tasks.length > 0);
6486
+ if (phases.length === 0) return undefined;
6487
+
6488
+ let total = 0;
6489
+ let closed = 0;
6490
+ let open = 0;
6491
+ const promptPhases = phases.map(phase => ({
6492
+ name: phase.name,
6493
+ tasks: phase.tasks.map(task => {
6494
+ total++;
6495
+ if (task.status === "completed" || task.status === "abandoned") {
6496
+ closed++;
6497
+ } else {
6498
+ open++;
6499
+ }
6500
+ return { content: task.content, status: task.status };
6501
+ }),
6502
+ }));
6503
+
6504
+ return prompt.render(goalTodoContextPrompt, {
6505
+ closed: String(closed),
6506
+ open: String(open),
6507
+ phases: promptPhases,
6508
+ total: String(total),
6509
+ });
6510
+ }
6511
+
6273
6512
  #normalizeImagesForModel(images: ImageContent[] | undefined): Promise<ImageContent[] | undefined> {
6274
6513
  return normalizeModelContextImages(images, { model: this.model });
6275
6514
  }
@@ -6526,7 +6765,10 @@ export class AgentSession {
6526
6765
 
6527
6766
  async promptCustomMessage<T = unknown>(
6528
6767
  message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details" | "attribution">,
6529
- options?: Pick<PromptOptions, "streamingBehavior" | "toolChoice"> & { queueChipText?: string },
6768
+ options?: Pick<PromptOptions, "streamingBehavior" | "toolChoice"> & {
6769
+ queueChipText?: string;
6770
+ queueOnly?: boolean;
6771
+ },
6530
6772
  ): Promise<void> {
6531
6773
  const textContent =
6532
6774
  typeof message.content === "string"
@@ -6546,6 +6788,16 @@ export class AgentSession {
6546
6788
  keywordNotices = this.#createMagicKeywordNotices(skillArgs);
6547
6789
  }
6548
6790
 
6791
+ if (options?.queueOnly) {
6792
+ if (!options.streamingBehavior) {
6793
+ throw new AgentBusyError();
6794
+ }
6795
+ for (const notice of keywordNotices) {
6796
+ await this.#queueCustomMessage(notice, options.streamingBehavior);
6797
+ }
6798
+ await this.#queueCustomMessage(message, options.streamingBehavior, options.queueChipText);
6799
+ return;
6800
+ }
6549
6801
  if (this.isStreaming) {
6550
6802
  if (!options?.streamingBehavior) {
6551
6803
  throw new AgentBusyError();
@@ -7111,6 +7363,40 @@ export class AgentSession {
7111
7363
  }
7112
7364
  }
7113
7365
 
7366
+ /** Queue a custom message without starting a turn, matching steer/follow-up delivery. */
7367
+ async #queueCustomMessage<T = unknown>(
7368
+ message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details" | "attribution">,
7369
+ deliverAs: "steer" | "followUp",
7370
+ queueChipText?: string,
7371
+ ): Promise<void> {
7372
+ const details =
7373
+ queueChipText !== undefined
7374
+ ? ({
7375
+ ...((message.details && typeof message.details === "object" ? message.details : {}) as Record<
7376
+ string,
7377
+ unknown
7378
+ >),
7379
+ __queueChipText: queueChipText,
7380
+ } as T)
7381
+ : message.details;
7382
+ const appMessage: CustomMessage<T> = {
7383
+ role: "custom",
7384
+ customType: message.customType,
7385
+ content: message.content,
7386
+ display: message.display,
7387
+ details,
7388
+ attribution: message.attribution ?? "agent",
7389
+ timestamp: Date.now(),
7390
+ };
7391
+ const normalizedAppMessage = await this.#normalizeAgentMessageImages(appMessage);
7392
+ if (deliverAs === "followUp") {
7393
+ this.agent.followUp(normalizedAppMessage);
7394
+ } else {
7395
+ this.agent.steer(normalizedAppMessage);
7396
+ }
7397
+ this.#scheduleIdleQueueDrain();
7398
+ }
7399
+
7114
7400
  /**
7115
7401
  * Send a custom message to the session. Creates a CustomMessageEntry.
7116
7402
  *
@@ -7353,6 +7639,74 @@ export class AgentSession {
7353
7639
  this.#todoPhases = this.#cloneTodoPhases(phases);
7354
7640
  }
7355
7641
 
7642
+ #isTodoInitResult(details: Record<string, unknown>, toolCallId: string | undefined): boolean {
7643
+ const detailOp = getStringProperty(details, "op");
7644
+ if (detailOp) return detailOp === "init";
7645
+ if (!toolCallId) return false;
7646
+ for (let i = this.agent.state.messages.length - 1; i >= 0; i--) {
7647
+ const message = this.agent.state.messages[i];
7648
+ if (!message) continue;
7649
+ const op = toolCallOpFromMessage(message, toolCallId);
7650
+ if (op) return op === "init";
7651
+ }
7652
+ return false;
7653
+ }
7654
+
7655
+ #buildReplanTitleContext(): string {
7656
+ const turns: TitleConversationTurn[] = [];
7657
+ for (
7658
+ let i = this.agent.state.messages.length - 1;
7659
+ i >= 0 && turns.length < REPLAN_TITLE_CONTEXT_TURN_LIMIT;
7660
+ i--
7661
+ ) {
7662
+ const message = this.agent.state.messages[i];
7663
+ if (!message) continue;
7664
+ const turn = titleConversationTurnFromMessage(message);
7665
+ if (turn) turns.push(turn);
7666
+ }
7667
+ turns.reverse();
7668
+ return formatTitleConversationContext(turns);
7669
+ }
7670
+
7671
+ #scheduleReplanTitleRefresh(): void {
7672
+ if (this.#replanTitleRefreshInFlight) return;
7673
+ if (!this.settings.get("title.refreshOnReplan")) return;
7674
+ if (this.sessionManager.titleSource === "user") return;
7675
+ const context = this.#buildReplanTitleContext();
7676
+ if (!context) return;
7677
+ const sessionId = this.sessionManager.getSessionId();
7678
+ const refresh = this.#refreshTitleAfterReplan(context, sessionId)
7679
+ .catch(err => {
7680
+ logger.warn("title-generator: replan refresh failed", {
7681
+ sessionId,
7682
+ error: err instanceof Error ? err.message : String(err),
7683
+ });
7684
+ })
7685
+ .finally(() => {
7686
+ if (this.#replanTitleRefreshInFlight === refresh) {
7687
+ this.#replanTitleRefreshInFlight = undefined;
7688
+ }
7689
+ });
7690
+ this.#replanTitleRefreshInFlight = refresh;
7691
+ }
7692
+
7693
+ async #refreshTitleAfterReplan(context: string, sessionId: string): Promise<void> {
7694
+ const title = await generateSessionTitle(
7695
+ context,
7696
+ this.#modelRegistry,
7697
+ this.settings,
7698
+ sessionId,
7699
+ this.model,
7700
+ provider => this.agent.metadataForProvider(provider),
7701
+ );
7702
+ if (!title) return;
7703
+ if (this.sessionManager.getSessionId() !== sessionId) return;
7704
+ if (!this.settings.get("title.refreshOnReplan")) return;
7705
+ if (this.sessionManager.titleSource === "user") return;
7706
+ const setSessionName = this.sessionManager.setSessionName as SetSessionNameWithTrigger;
7707
+ await setSessionName.call(this.sessionManager, title, "auto", "replan");
7708
+ }
7709
+
7356
7710
  #syncTodoPhasesFromBranch(): void {
7357
7711
  const phases = getLatestTodoPhasesFromEntries(this.sessionManager.getBranch());
7358
7712
  // Strip completed/abandoned tasks — they were done in a previous run,
@@ -7495,9 +7849,11 @@ export class AgentSession {
7495
7849
  // running advisor turn could otherwise finish, emit `message_end`, and recreate
7496
7850
  // `<old>/__advisor.jsonl`. #resetAdvisorSessionState (after newSession) re-primes
7497
7851
  // 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();
7852
+ for (const a of this.#advisors) {
7853
+ a.agentUnsubscribe?.();
7854
+ a.agentUnsubscribe = undefined;
7855
+ await a.recorder.close();
7856
+ }
7501
7857
  try {
7502
7858
  await this.sessionManager.dropSession(previousSessionFile);
7503
7859
  } catch (err) {
@@ -7551,8 +7907,9 @@ export class AgentSession {
7551
7907
  /**
7552
7908
  * Set a display name for the current session.
7553
7909
  */
7554
- setSessionName(name: string, source: "auto" | "user" = "auto"): Promise<boolean> {
7555
- return this.sessionManager.setSessionName(name, source);
7910
+ setSessionName(name: string, source: "auto" | "user" = "auto", trigger?: SessionNameTrigger): Promise<boolean> {
7911
+ const setSessionName = this.sessionManager.setSessionName as SetSessionNameWithTrigger;
7912
+ return setSessionName.call(this.sessionManager, name, source, trigger);
7556
7913
  }
7557
7914
 
7558
7915
  /**
@@ -7630,16 +7987,24 @@ export class AgentSession {
7630
7987
  /**
7631
7988
  * Set model directly.
7632
7989
  * 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.
7990
+ * refreshing OAuth or running command-backed key programs). The active
7991
+ * session switches by default; when `currentContextTokens` is provided and
7992
+ * exceeds the refreshed candidate's context window, the live switch is
7993
+ * skipped while role persistence still runs. Returns whether the active
7994
+ * model actually switched, computed against the refreshed metadata so
7995
+ * dynamic providers (e.g. llama.cpp) honor their post-load contextWindow.
7636
7996
  * @throws Error if no API key available for the model
7637
7997
  */
7638
7998
  async setModel(
7639
7999
  model: Model,
7640
8000
  role: string = "default",
7641
- options?: { selector?: string; thinkingLevel?: ThinkingLevel; persist?: boolean },
7642
- ): Promise<void> {
8001
+ options?: {
8002
+ selector?: string;
8003
+ thinkingLevel?: ThinkingLevel;
8004
+ persist?: boolean;
8005
+ currentContextTokens?: number;
8006
+ },
8007
+ ): Promise<{ switched: boolean }> {
7643
8008
  const previousEditMode = this.#resolveActiveEditMode();
7644
8009
  if (!this.#modelRegistry.hasConfiguredAuth(model)) {
7645
8010
  throw new Error(`No API key for ${model.provider}/${model.id}`);
@@ -7647,21 +8012,35 @@ export class AgentSession {
7647
8012
 
7648
8013
  const targetModel = await this.#modelRegistry.refreshSelectedModelMetadata(model);
7649
8014
 
7650
- this.#clearActiveRetryFallback();
7651
- this.#setModelWithProviderSessionReset(targetModel);
7652
- this.sessionManager.appendModelChange(`${targetModel.provider}/${targetModel.id}`, role);
8015
+ const currentContextTokens = options?.currentContextTokens ?? 0;
8016
+ const targetContextWindow = targetModel.contextWindow ?? 0;
8017
+ const switched = !(
8018
+ currentContextTokens > 0 &&
8019
+ targetContextWindow > 0 &&
8020
+ currentContextTokens > targetContextWindow
8021
+ );
8022
+
8023
+ if (switched) {
8024
+ this.#clearActiveRetryFallback();
8025
+ this.#setModelWithProviderSessionReset(targetModel);
8026
+ this.sessionManager.appendModelChange(`${targetModel.provider}/${targetModel.id}`, role);
8027
+ }
7653
8028
  if (options?.persist) {
7654
8029
  this.settings.setModelRole(
7655
8030
  role,
7656
8031
  this.#formatRoleModelValue(role, targetModel, options.selector, options.thinkingLevel),
7657
8032
  );
7658
8033
  }
8034
+ if (!switched) {
8035
+ return { switched: false };
8036
+ }
7659
8037
  this.settings.getStorage()?.recordModelUsage(`${targetModel.provider}/${targetModel.id}`);
7660
8038
 
7661
8039
  // Re-apply thinking for the newly selected model. Prefer the model's
7662
8040
  // configured defaultLevel; otherwise preserve the current level (or auto).
7663
8041
  this.#reapplyThinkingLevel(targetModel.thinking?.defaultLevel);
7664
8042
  await this.#syncAfterModelChange(previousEditMode);
8043
+ return { switched: true };
7665
8044
  }
7666
8045
 
7667
8046
  /**
@@ -8186,7 +8565,7 @@ export class AgentSession {
8186
8565
  await this.sessionManager.rewriteEntries();
8187
8566
  const sessionContext = this.buildDisplaySessionContext();
8188
8567
  this.agent.replaceMessages(sessionContext.messages);
8189
- this.#advisorRuntime?.reset();
8568
+ this.#resetAllAdvisorRuntimes();
8190
8569
  this.#syncTodoPhasesFromBranch();
8191
8570
  this.#closeCodexProviderSessionsForHistoryRewrite();
8192
8571
  return result;
@@ -8223,7 +8602,7 @@ export class AgentSession {
8223
8602
 
8224
8603
  const sessionContext = this.buildDisplaySessionContext();
8225
8604
  this.agent.replaceMessages(sessionContext.messages);
8226
- this.#advisorRuntime?.reset();
8605
+ this.#resetAllAdvisorRuntimes();
8227
8606
  this.#syncTodoPhasesFromBranch();
8228
8607
  this.#closeCodexProviderSessionsForHistoryRewrite();
8229
8608
  return result;
@@ -8274,7 +8653,7 @@ export class AgentSession {
8274
8653
  await this.sessionManager.rewriteEntries();
8275
8654
  const sessionContext = this.buildDisplaySessionContext();
8276
8655
  this.agent.replaceMessages(sessionContext.messages);
8277
- this.#advisorRuntime?.reset();
8656
+ this.#resetAllAdvisorRuntimes();
8278
8657
  this.#closeCodexProviderSessionsForHistoryRewrite();
8279
8658
  return { removed };
8280
8659
  }
@@ -8331,7 +8710,7 @@ export class AgentSession {
8331
8710
  await this.sessionManager.rewriteEntries();
8332
8711
  const sessionContext = this.buildDisplaySessionContext();
8333
8712
  this.agent.replaceMessages(sessionContext.messages);
8334
- this.#advisorRuntime?.reset();
8713
+ this.#resetAllAdvisorRuntimes();
8335
8714
  this.#closeCodexProviderSessionsForHistoryRewrite();
8336
8715
 
8337
8716
  return {
@@ -8427,7 +8806,11 @@ export class AgentSession {
8427
8806
  compactionCandidates = this.#getCompactionModelCandidates(availableModels);
8428
8807
  }
8429
8808
  const pathEntries = this.sessionManager.getBranch();
8430
- const preparation = prepareCompaction(pathEntries, effectiveSettings);
8809
+ const preparation = prepareCompaction(
8810
+ pathEntries,
8811
+ effectiveSettings,
8812
+ await this.#runnableCompactionCandidates(compactionCandidates, this.sessionId),
8813
+ );
8431
8814
  if (!preparation) {
8432
8815
  // Check why we can't compact
8433
8816
  const lastEntry = pathEntries[pathEntries.length - 1];
@@ -8617,7 +9000,7 @@ export class AgentSession {
8617
9000
  // plan reference. Clear the sent-flag so #buildPlanReferenceMessage re-reads
8618
9001
  // the plan from disk and re-injects it on the next turn (issue #1246).
8619
9002
  this.#planReferenceSent = false;
8620
- this.#advisorRuntime?.reset();
9003
+ this.#resetAllAdvisorRuntimes();
8621
9004
  this.#syncTodoPhasesFromBranch();
8622
9005
  this.#closeCodexProviderSessionsForHistoryRewrite();
8623
9006
 
@@ -8881,7 +9264,7 @@ export class AgentSession {
8881
9264
  // Rebuild agent messages from session
8882
9265
  const sessionContext = this.buildDisplaySessionContext();
8883
9266
  this.agent.replaceMessages(sessionContext.messages);
8884
- this.#advisorRuntime?.reset();
9267
+ this.#resetAllAdvisorRuntimes();
8885
9268
  this.#syncTodoPhasesFromBranch();
8886
9269
 
8887
9270
  return { document: handoffText, savedPath };
@@ -10201,6 +10584,17 @@ export class AgentSession {
10201
10584
  return this.#resolveCompactionModelCandidates(this.model, availableModels, filter);
10202
10585
  }
10203
10586
 
10587
+ /**
10588
+ * Compaction candidates that can actually run — those with a resolvable API
10589
+ * key, matching the per-candidate getApiKey gate the execution loop applies.
10590
+ * Re-expansion reusability (prepareCompaction) must judge remote-preserve
10591
+ * reuse against these, not against candidates the loop would skip at runtime.
10592
+ */
10593
+ async #runnableCompactionCandidates(candidates: readonly Model[], sessionId: string | undefined): Promise<Model[]> {
10594
+ const keys = await Promise.all(candidates.map(model => this.#modelRegistry.getApiKey(model, sessionId)));
10595
+ return candidates.filter((_, index) => keys[index] !== undefined);
10596
+ }
10597
+
10204
10598
  #resolveCompactionModelCandidates(
10205
10599
  preferredModel: Model | null | undefined,
10206
10600
  availableModels: Model[],
@@ -10297,6 +10691,9 @@ export class AgentSession {
10297
10691
  // via resolveCompactionEffort so unsupported-effort models
10298
10692
  // (xai-oauth/grok-build) don't trip requireSupportedEffort.
10299
10693
  thinkingLevel: this.thinkingLevel,
10694
+ tools: this.agent.state.tools,
10695
+ sessionId: this.sessionId,
10696
+ promptCacheKey: this.sessionId,
10300
10697
  },
10301
10698
  );
10302
10699
  } catch (error) {
@@ -10616,15 +11013,21 @@ export class AgentSession {
10616
11013
 
10617
11014
  // "overflow" forces context-full because the input itself is broken — a handoff
10618
11015
  // 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.
11016
+ // so a handoff request on the existing context is still viable.
10622
11017
  let action: "context-full" | "handoff" | "snapcompact" =
10623
11018
  compactionSettings.strategy === "snapcompact"
10624
11019
  ? "snapcompact"
10625
11020
  : compactionSettings.strategy === "handoff" && reason !== "overflow" && !suppressHandoff
10626
11021
  ? "handoff"
10627
11022
  : "context-full";
11023
+ if (action === "snapcompact" && this.model && !this.model.input.includes("image")) {
11024
+ this.emitNotice(
11025
+ "warning",
11026
+ `snapcompact needs a vision-capable active model (${this.model.id} is text-only); using context-full auto-compaction instead.`,
11027
+ "compaction",
11028
+ );
11029
+ action = "context-full";
11030
+ }
10628
11031
  // Abort any older auto-compaction before installing this run's controller.
10629
11032
  this.#autoCompactionAbortController?.abort();
10630
11033
  const autoCompactionAbortController = new AbortController();
@@ -10703,7 +11106,11 @@ export class AgentSession {
10703
11106
 
10704
11107
  const pathEntries = this.sessionManager.getBranch();
10705
11108
 
10706
- const preparation = prepareCompaction(pathEntries, compactionSettings);
11109
+ const autoCompactionCandidates = await this.#runnableCompactionCandidates(
11110
+ this.#getCompactionModelCandidates(availableModels),
11111
+ this.sessionId,
11112
+ );
11113
+ const preparation = prepareCompaction(pathEntries, compactionSettings, autoCompactionCandidates);
10707
11114
  if (!preparation) {
10708
11115
  await this.#emitSessionEvent({
10709
11116
  type: "auto_compaction_end",
@@ -10766,21 +11173,15 @@ export class AgentSession {
10766
11173
  // + a summary message carrying the imaged archive at FRAME_TOKEN_ESTIMATE
10767
11174
  // per frame; #computeSnapcompactMaxFrames sizes the frame cap from the
10768
11175
  // 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.
11176
+ // tick. Any local blocker (non-ASCII transcript, kept-history too large,
11177
+ // post-render overflow) downgrades auto maintenance to a context-full LLM
11178
+ // summary instead of wedging the session (#3659) — auto runs the default
11179
+ // strategy on the user's behalf, so a fallback that lets the session keep
11180
+ // running is the right behavior. Manual `/compact snapcompact` keeps the
11181
+ // local-only contract (#3599): the user explicitly picked it.
10771
11182
  let snapcompactResult: snapcompact.CompactionResult | undefined;
11183
+ let snapcompactBlocker: string | undefined;
10772
11184
  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
11185
  const text = snapcompact.serializeConversation(
10785
11186
  convertToLlm(preparation.messagesToSummarize.concat(preparation.turnPrefixMessages)),
10786
11187
  );
@@ -10791,55 +11192,46 @@ export class AgentSession {
10791
11192
  model: this.model?.id,
10792
11193
  unrenderableRatio: renderScan.unrenderableRatio,
10793
11194
  });
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", {
11195
+ snapcompactBlocker = `snapcompact disabled: high non-ASCII rate detected (${percent}%); using context-full auto-compaction instead.`;
11196
+ } else {
11197
+ const maxFrames = this.#computeSnapcompactMaxFrames(preparation, compactionSettings);
11198
+ if (maxFrames < 1) {
11199
+ logger.warn("Snapcompact skipped: kept history alone exceeds the context budget", {
10831
11200
  model: this.model?.id,
10832
- projected,
10833
- budget,
10834
11201
  });
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.");
11202
+ snapcompactBlocker =
11203
+ "snapcompact: kept history alone exceeds the context budget; using context-full auto-compaction instead.";
11204
+ } else {
11205
+ snapcompactResult = await snapcompact.compact(preparation, {
11206
+ convertToLlm,
11207
+ model: this.model,
11208
+ shape: snapcompact.resolveShape(this.model, this.settings.get("snapcompact.shape")),
11209
+ maxFrames,
11210
+ });
11211
+ if (snapcompactResult) {
11212
+ const ctxWindow = this.model?.contextWindow ?? 0;
11213
+ const budget =
11214
+ ctxWindow > 0
11215
+ ? ctxWindow - effectiveReserveTokens(ctxWindow, compactionSettings)
11216
+ : Number.POSITIVE_INFINITY;
11217
+ const projected = this.#projectSnapcompactContextTokens(preparation, snapcompactResult);
11218
+ if (projected > budget) {
11219
+ logger.warn("Snapcompact still overflows the window after frame-budget sizing", {
11220
+ model: this.model?.id,
11221
+ projected,
11222
+ budget,
11223
+ });
11224
+ snapcompactBlocker =
11225
+ "snapcompact could not bring the context under the limit; using context-full auto-compaction instead.";
11226
+ snapcompactResult = undefined;
11227
+ }
11228
+ }
10841
11229
  }
10842
11230
  }
11231
+ if (snapcompactBlocker) {
11232
+ this.emitNotice("warning", snapcompactBlocker, "compaction");
11233
+ action = "context-full";
11234
+ }
10843
11235
  }
10844
11236
 
10845
11237
  if (compactionPrep.kind === "fromHook") {
@@ -10891,6 +11283,9 @@ export class AgentSession {
10891
11283
  // site. Clamped per-model inside compact() via
10892
11284
  // resolveCompactionEffort.
10893
11285
  thinkingLevel: this.thinkingLevel,
11286
+ tools: this.agent.state.tools,
11287
+ sessionId: this.sessionId,
11288
+ promptCacheKey: this.sessionId,
10894
11289
  },
10895
11290
  );
10896
11291
  break;
@@ -11007,7 +11402,7 @@ export class AgentSession {
11007
11402
  // plan reference. Clear the sent-flag so #buildPlanReferenceMessage re-reads
11008
11403
  // the plan from disk and re-injects it on the next turn (issue #1246).
11009
11404
  this.#planReferenceSent = false;
11010
- this.#advisorRuntime?.reset();
11405
+ this.#resetAllAdvisorRuntimes();
11011
11406
  this.#syncTodoPhasesFromBranch();
11012
11407
  this.#closeCodexProviderSessionsForHistoryRewrite();
11013
11408
 
@@ -12853,7 +13248,7 @@ export class AgentSession {
12853
13248
  this.#applyThinkingLevelToAgent(previousThinkingLevel);
12854
13249
  this.agent.serviceTier = previousServiceTier;
12855
13250
  this.#syncTodoPhasesFromBranch();
12856
- this.#advisorRuntime?.reset();
13251
+ this.#resetAllAdvisorRuntimes();
12857
13252
  this.#reconnectToAgent();
12858
13253
  if (restoreMcpError) {
12859
13254
  throw restoreMcpError;
@@ -13255,10 +13650,11 @@ export class AgentSession {
13255
13650
  let totalInput = 0;
13256
13651
  let totalOutput = 0;
13257
13652
  let totalCacheRead = 0;
13653
+ let totalReasoning = 0;
13258
13654
  let totalCacheWrite = 0;
13259
13655
  let totalCost = 0;
13260
-
13261
13656
  let totalPremiumRequests = 0;
13657
+
13262
13658
  const getTaskToolUsage = (details: unknown): Usage | undefined => {
13263
13659
  if (!details || typeof details !== "object") return undefined;
13264
13660
  const record = details as Record<string, unknown>;
@@ -13273,6 +13669,7 @@ export class AgentSession {
13273
13669
  toolCalls += assistantMsg.content.filter(c => c.type === "toolCall").length;
13274
13670
  totalInput += assistantMsg.usage.input;
13275
13671
  totalOutput += assistantMsg.usage.output;
13672
+ totalReasoning += assistantMsg.usage.reasoningTokens ?? 0;
13276
13673
  totalCacheRead += assistantMsg.usage.cacheRead;
13277
13674
  totalCacheWrite += assistantMsg.usage.cacheWrite;
13278
13675
  totalPremiumRequests += assistantMsg.usage.premiumRequests ?? 0;
@@ -13284,6 +13681,7 @@ export class AgentSession {
13284
13681
  if (usage) {
13285
13682
  totalInput += usage.input;
13286
13683
  totalOutput += usage.output;
13684
+ totalReasoning += usage.reasoningTokens ?? 0;
13287
13685
  totalCacheRead += usage.cacheRead;
13288
13686
  totalCacheWrite += usage.cacheWrite;
13289
13687
  totalPremiumRequests += usage.premiumRequests ?? 0;
@@ -13303,6 +13701,7 @@ export class AgentSession {
13303
13701
  tokens: {
13304
13702
  input: totalInput,
13305
13703
  output: totalOutput,
13704
+ reasoning: totalReasoning,
13306
13705
  cacheRead: totalCacheRead,
13307
13706
  cacheWrite: totalCacheWrite,
13308
13707
  total: totalInput + totalOutput + totalCacheRead + totalCacheWrite,
@@ -13835,6 +14234,23 @@ export class AgentSession {
13835
14234
  return this.setAdvisorEnabled(!this.#advisorEnabled);
13836
14235
  }
13837
14236
 
14237
+ /**
14238
+ * Replace the live advisor roster from an edited `WATCHDOG.yml` (the `/advisor
14239
+ * configure` save path). Swaps the configs + shared baseline, then rebuilds the
14240
+ * runtimes in place so the change applies without a restart. When the advisor is
14241
+ * disabled the new configs are simply stored for the next enable.
14242
+ *
14243
+ * @returns the number of advisors active after the rebuild.
14244
+ */
14245
+ applyAdvisorConfigs(advisors: AdvisorConfig[], sharedInstructions: string | undefined): number {
14246
+ this.#advisorConfigs = advisors;
14247
+ this.#advisorSharedInstructions = sharedInstructions;
14248
+ if (!this.#advisorEnabled) return 0;
14249
+ this.#stopAdvisorRuntime();
14250
+ this.#buildAdvisorRuntime(true);
14251
+ return this.#advisors.length;
14252
+ }
14253
+
13838
14254
  /**
13839
14255
  * Whether the advisor setting is enabled for this session.
13840
14256
  */
@@ -13849,7 +14265,17 @@ export class AgentSession {
13849
14265
  * not merely the setting. Drives the status-line badge and `/dump advisor`.
13850
14266
  */
13851
14267
  isAdvisorActive(): boolean {
13852
- return this.#advisorAgent !== undefined;
14268
+ return this.#advisors.length > 0;
14269
+ }
14270
+
14271
+ /**
14272
+ * The names of the tools available to advisors this session (the pool a
14273
+ * `/advisor configure` editor lists). The advisor is a full agent, so this is the
14274
+ * full built tool set; a tool whose optional factory returns null (e.g. lsp with
14275
+ * no servers) is absent.
14276
+ */
14277
+ getAdvisorAvailableToolNames(): string[] {
14278
+ return (this.#advisorTools ?? []).map(tool => tool.name);
13853
14279
  }
13854
14280
 
13855
14281
  /**
@@ -13860,7 +14286,7 @@ export class AgentSession {
13860
14286
  * (`streamFn`, `promptCacheKey`, `providerSessionState`, ...).
13861
14287
  */
13862
14288
  getAdvisorAgent(): Agent | undefined {
13863
- return this.#advisorAgent;
14289
+ return this.#advisors[0]?.agent;
13864
14290
  }
13865
14291
 
13866
14292
  /**
@@ -13868,23 +14294,59 @@ export class AgentSession {
13868
14294
  */
13869
14295
  getAdvisorStats(): AdvisorStats {
13870
14296
  const configured = this.#advisorEnabled;
13871
- const advisor = this.#advisorAgent;
13872
- if (!advisor) {
14297
+ const advisors = this.#advisors.map(a => this.#computeAdvisorStat(a));
14298
+ if (advisors.length === 0) {
13873
14299
  return {
13874
14300
  configured,
13875
14301
  active: false,
13876
14302
  contextWindow: 0,
13877
14303
  contextTokens: 0,
13878
- tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
14304
+ tokens: { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
13879
14305
  cost: 0,
13880
14306
  messages: { user: 0, assistant: 0, total: 0 },
14307
+ advisors: [],
13881
14308
  };
13882
14309
  }
13883
- const model = advisor.state.model;
13884
- const messages = advisor.state.messages;
14310
+ const tokens = { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0, total: 0 };
14311
+ const messages = { user: 0, assistant: 0, total: 0 };
14312
+ let cost = 0;
14313
+ let contextTokens = 0;
14314
+ for (const a of advisors) {
14315
+ tokens.input += a.tokens.input;
14316
+ tokens.output += a.tokens.output;
14317
+ tokens.reasoning += a.tokens.reasoning;
14318
+ tokens.cacheRead += a.tokens.cacheRead;
14319
+ tokens.cacheWrite += a.tokens.cacheWrite;
14320
+ tokens.total += a.tokens.total;
14321
+ messages.user += a.messages.user;
14322
+ messages.assistant += a.messages.assistant;
14323
+ messages.total += a.messages.total;
14324
+ cost += a.cost;
14325
+ contextTokens += a.contextTokens;
14326
+ }
14327
+ // Single-advisor displays read the top-level model/window directly; surface the
14328
+ // first advisor's so the legacy status line stays byte-identical.
14329
+ return {
14330
+ configured,
14331
+ active: true,
14332
+ model: advisors[0].model,
14333
+ contextWindow: advisors[0].contextWindow,
14334
+ contextTokens,
14335
+ tokens,
14336
+ cost,
14337
+ messages,
14338
+ advisors,
14339
+ };
14340
+ }
14341
+
14342
+ /** Compute one advisor's stats slice (tokens, cost, context, message counts). */
14343
+ #computeAdvisorStat(advisor: ActiveAdvisor): PerAdvisorStat {
14344
+ const model = advisor.agent.state.model;
14345
+ const messages = advisor.agent.state.messages;
13885
14346
  const contextTokens = this.#estimateAdvisorContextTokens(messages);
13886
14347
  let input = 0;
13887
14348
  let output = 0;
14349
+ let reasoning = 0;
13888
14350
  let cacheRead = 0;
13889
14351
  let cacheWrite = 0;
13890
14352
  let cost = 0;
@@ -13897,24 +14359,18 @@ export class AgentSession {
13897
14359
  const assistantMsg = message as AssistantMessage;
13898
14360
  input += assistantMsg.usage.input;
13899
14361
  output += assistantMsg.usage.output;
14362
+ reasoning += assistantMsg.usage.reasoningTokens ?? 0;
13900
14363
  cacheRead += assistantMsg.usage.cacheRead;
13901
14364
  cacheWrite += assistantMsg.usage.cacheWrite;
13902
14365
  cost += assistantMsg.usage.cost.total;
13903
14366
  }
13904
14367
  }
13905
14368
  return {
13906
- configured,
13907
- active: true,
14369
+ name: advisor.name,
13908
14370
  model,
13909
14371
  contextWindow: model.contextWindow ?? 0,
13910
14372
  contextTokens,
13911
- tokens: {
13912
- input,
13913
- output,
13914
- cacheRead,
13915
- cacheWrite,
13916
- total: input + output + cacheRead + cacheWrite,
13917
- },
14373
+ tokens: { input, output, reasoning, cacheRead, cacheWrite, total: input + output + cacheRead + cacheWrite },
13918
14374
  cost,
13919
14375
  messages: { user, assistant, total: messages.length },
13920
14376
  };
@@ -13930,19 +14386,30 @@ export class AgentSession {
13930
14386
  ? "Advisor setting is enabled, but no model is assigned to the 'advisor' role."
13931
14387
  : "Advisor is disabled.";
13932
14388
  }
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}.`;
14389
+ if (stats.advisors.length <= 1) {
14390
+ const s = stats.advisors[0];
14391
+ const contextLine =
14392
+ s.contextWindow > 0
14393
+ ? `Context: ${s.contextTokens.toLocaleString()} / ${s.contextWindow.toLocaleString()} tokens (${Math.round((s.contextTokens / s.contextWindow) * 100)}%)`
14394
+ : `Context: ${s.contextTokens.toLocaleString()} tokens`;
14395
+ const spendParts = [`${s.tokens.input.toLocaleString()} input`, `${s.tokens.output.toLocaleString()} output`];
14396
+ if (s.tokens.cacheRead > 0) spendParts.push(`${s.tokens.cacheRead.toLocaleString()} cache read`);
14397
+ if (s.tokens.cacheWrite > 0) spendParts.push(`${s.tokens.cacheWrite.toLocaleString()} cache write`);
14398
+ const spendLine = `Spend: ${spendParts.join(", ")}, $${s.cost.toFixed(4)}`;
14399
+ return `Advisor is enabled (${s.model.provider}/${s.model.id}). ${contextLine}. ${spendLine}.`;
14400
+ }
14401
+ const lines = [`Advisors enabled (${stats.advisors.length}):`];
14402
+ for (const s of stats.advisors) {
14403
+ const ctx =
14404
+ s.contextWindow > 0
14405
+ ? `${s.contextTokens.toLocaleString()} / ${s.contextWindow.toLocaleString()} (${Math.round((s.contextTokens / s.contextWindow) * 100)}%)`
14406
+ : `${s.contextTokens.toLocaleString()}`;
14407
+ lines.push(` • ${s.name} (${s.model.provider}/${s.model.id}) — context ${ctx} tokens, $${s.cost.toFixed(4)}`);
14408
+ }
14409
+ lines.push(
14410
+ `Totals: ${stats.tokens.input.toLocaleString()} input, ${stats.tokens.output.toLocaleString()} output, $${stats.cost.toFixed(4)}.`,
14411
+ );
14412
+ return lines.join("\n");
13946
14413
  }
13947
14414
 
13948
14415
  /**
@@ -13986,18 +14453,21 @@ export class AgentSession {
13986
14453
  * {@link formatSessionAsText}. Returns null when no advisor is active.
13987
14454
  */
13988
14455
  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
- });
14456
+ if (this.#advisors.length === 0) return null;
14457
+ const dump = (a: ActiveAdvisor): string =>
14458
+ options?.compact
14459
+ ? formatSessionHistoryMarkdown(a.agent.state.messages)
14460
+ : formatSessionDumpText({
14461
+ messages: a.agent.state.messages,
14462
+ systemPrompt: a.agent.state.systemPrompt,
14463
+ model: a.agent.state.model,
14464
+ thinkingLevel: a.agent.state.thinkingLevel,
14465
+ tools: a.agent.state.tools,
14466
+ });
14467
+ if (this.#advisors.length === 1) return dump(this.#advisors[0]);
14468
+ return this.#advisors
14469
+ .map(a => `### Advisor: ${a.name} (${a.agent.state.model.provider}/${a.agent.state.model.id})\n\n${dump(a)}`)
14470
+ .join("\n\n");
14001
14471
  }
14002
14472
 
14003
14473
  // =========================================================================