@oh-my-pi/pi-coding-agent 15.13.3 → 16.0.1

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 (93) hide show
  1. package/CHANGELOG.md +155 -133
  2. package/dist/cli.js +621 -530
  3. package/dist/types/advisor/__tests__/advisor.test.d.ts +1 -0
  4. package/dist/types/advisor/advise-tool.d.ts +58 -0
  5. package/dist/types/advisor/index.d.ts +3 -0
  6. package/dist/types/advisor/runtime.d.ts +52 -0
  7. package/dist/types/advisor/watchdog.d.ts +5 -0
  8. package/dist/types/config/model-roles.d.ts +1 -1
  9. package/dist/types/config/settings-schema.d.ts +66 -5
  10. package/dist/types/discovery/helpers.d.ts +7 -0
  11. package/dist/types/eval/__tests__/prelude-agent.test.d.ts +1 -0
  12. package/dist/types/extensibility/plugins/runtime-config.d.ts +3 -0
  13. package/dist/types/modes/components/advisor-message.d.ts +9 -0
  14. package/dist/types/modes/components/assistant-message.d.ts +1 -0
  15. package/dist/types/modes/controllers/command-controller.d.ts +3 -1
  16. package/dist/types/modes/interactive-mode.d.ts +3 -1
  17. package/dist/types/modes/types.d.ts +8 -1
  18. package/dist/types/sdk.d.ts +3 -3
  19. package/dist/types/session/agent-session.d.ts +81 -2
  20. package/dist/types/session/session-history-format.d.ts +4 -0
  21. package/dist/types/session/session-manager.d.ts +4 -1
  22. package/dist/types/session/yield-queue.d.ts +2 -0
  23. package/dist/types/task/index.d.ts +21 -0
  24. package/dist/types/tools/github-cache.d.ts +5 -4
  25. package/dist/types/tools/job.d.ts +1 -0
  26. package/dist/types/tools/path-utils.d.ts +1 -0
  27. package/dist/types/tools/report-tool-issue.d.ts +0 -1
  28. package/dist/types/web/search/index.d.ts +2 -2
  29. package/dist/types/web/search/provider.d.ts +2 -0
  30. package/package.json +13 -13
  31. package/src/advisor/__tests__/advisor.test.ts +586 -0
  32. package/src/advisor/advise-tool.ts +87 -0
  33. package/src/advisor/index.ts +3 -0
  34. package/src/advisor/runtime.ts +248 -0
  35. package/src/advisor/watchdog.ts +83 -0
  36. package/src/cli/args.ts +1 -0
  37. package/src/collab/host.ts +1 -1
  38. package/src/config/model-roles.ts +13 -1
  39. package/src/config/settings-schema.ts +65 -6
  40. package/src/discovery/claude-plugins.ts +3 -42
  41. package/src/discovery/github.ts +101 -6
  42. package/src/discovery/helpers.ts +11 -0
  43. package/src/eval/__tests__/prelude-agent.test.ts +73 -0
  44. package/src/eval/js/shared/prelude.txt +12 -3
  45. package/src/eval/py/prelude.py +26 -2
  46. package/src/extensibility/custom-commands/bundled/review/index.ts +289 -80
  47. package/src/extensibility/plugins/loader.ts +3 -2
  48. package/src/extensibility/plugins/manager.ts +4 -3
  49. package/src/extensibility/plugins/marketplace/fetcher.ts +32 -34
  50. package/src/extensibility/plugins/runtime-config.ts +9 -0
  51. package/src/internal-urls/docs-index.generated.ts +10 -9
  52. package/src/internal-urls/issue-pr-protocol.ts +8 -4
  53. package/src/main.ts +9 -1
  54. package/src/modes/acp/acp-agent.ts +3 -3
  55. package/src/modes/components/advisor-message.ts +99 -0
  56. package/src/modes/components/agent-hub.ts +7 -0
  57. package/src/modes/components/assistant-message.ts +86 -0
  58. package/src/modes/components/settings-defs.ts +7 -0
  59. package/src/modes/components/status-line/segments.ts +20 -7
  60. package/src/modes/components/tips.txt +1 -1
  61. package/src/modes/controllers/command-controller.ts +69 -2
  62. package/src/modes/controllers/extension-ui-controller.ts +4 -3
  63. package/src/modes/controllers/input-controller.ts +1 -0
  64. package/src/modes/controllers/selector-controller.ts +7 -0
  65. package/src/modes/interactive-mode.ts +59 -2
  66. package/src/modes/rpc/rpc-mode.ts +3 -3
  67. package/src/modes/runtime-init.ts +2 -1
  68. package/src/modes/types.ts +8 -1
  69. package/src/modes/utils/ui-helpers.ts +9 -0
  70. package/src/prompts/advisor/advise-tool.md +1 -0
  71. package/src/prompts/advisor/system.md +31 -0
  72. package/src/prompts/agents/designer.md +8 -0
  73. package/src/prompts/review-request.md +1 -1
  74. package/src/prompts/system/subagent-system-prompt.md +4 -1
  75. package/src/prompts/tools/eval.md +13 -3
  76. package/src/prompts/tools/irc.md +1 -1
  77. package/src/sdk.ts +61 -14
  78. package/src/session/agent-session.ts +667 -13
  79. package/src/session/session-dump-format.ts +15 -131
  80. package/src/session/session-history-format.ts +30 -11
  81. package/src/session/session-manager.ts +3 -1
  82. package/src/session/yield-queue.ts +5 -1
  83. package/src/slash-commands/builtin-registry.ts +105 -4
  84. package/src/system-prompt.ts +1 -1
  85. package/src/task/executor.ts +5 -4
  86. package/src/task/index.ts +70 -9
  87. package/src/tools/github-cache.ts +32 -7
  88. package/src/tools/job.ts +14 -1
  89. package/src/tools/path-utils.ts +33 -2
  90. package/src/tools/report-tool-issue.ts +2 -7
  91. package/src/web/scrapers/docs-rs.ts +2 -3
  92. package/src/web/search/index.ts +2 -2
  93. package/src/web/search/provider.ts +14 -2
@@ -22,7 +22,7 @@ import type { InMemorySnapshotStore } from "@oh-my-pi/hashline";
22
22
  import {
23
23
  type AfterToolCallContext,
24
24
  type AfterToolCallResult,
25
- type Agent,
25
+ Agent,
26
26
  AgentBusyError,
27
27
  type AgentEvent,
28
28
  type AgentMessage,
@@ -30,7 +30,9 @@ import {
30
30
  type AgentTool,
31
31
  AppendOnlyContextManager,
32
32
  type AsideMessage,
33
+ type CompactionSummaryMessage,
33
34
  resolveTelemetry,
35
+ STREAM_INTERRUPTED_AFTER_CONTENT_STOP_DETAIL,
34
36
  ThinkingLevel,
35
37
  } from "@oh-my-pi/pi-agent-core";
36
38
 
@@ -54,6 +56,8 @@ import {
54
56
  generateHandoff,
55
57
  prepareCompaction,
56
58
  resolveThresholdTokens,
59
+ type SessionEntry,
60
+ type SessionMessageEntry,
57
61
  type ShakeConfig,
58
62
  type ShakeRegion,
59
63
  type SummaryOptions,
@@ -114,6 +118,16 @@ import {
114
118
  Snowflake,
115
119
  } from "@oh-my-pi/pi-utils";
116
120
  import * as snapcompact from "@oh-my-pi/snapcompact";
121
+ import {
122
+ AdviseTool,
123
+ type AdvisorAgent,
124
+ type AdvisorMessageDetails,
125
+ type AdvisorNote,
126
+ AdvisorRuntime,
127
+ type AdvisorSeverity,
128
+ formatAdvisorBatchContent,
129
+ isInterruptingSeverity,
130
+ } from "../advisor";
117
131
  import { type AsyncJob, type AsyncJobDeliveryState, AsyncJobManager } from "../async";
118
132
  import { classifyDifficulty } from "../auto-thinking/classifier";
119
133
  import { reset as resetCapabilities } from "../capability";
@@ -129,6 +143,7 @@ import {
129
143
  parseModelString,
130
144
  type ResolvedModelRoleValue,
131
145
  resolveModelRoleValue,
146
+ resolveRoleSelection,
132
147
  } from "../config/model-resolver";
133
148
  import { MODEL_ROLE_IDS } from "../config/model-roles";
134
149
  import { expandPromptTemplate, type PromptTemplate } from "../config/prompt-templates";
@@ -189,6 +204,7 @@ import { computeNonMessageTokens } from "../modes/utils/context-usage";
189
204
  import { containsWorkflow, WORKFLOW_NOTICE } from "../modes/workflow";
190
205
  import { createPlanReadMatcher } from "../plan-mode/plan-protection";
191
206
  import type { PlanModeState } from "../plan-mode/state";
207
+ import advisorSystemPrompt from "../prompts/advisor/system.md" with { type: "text" };
192
208
  import autoContinuePrompt from "../prompts/system/auto-continue.md" with { type: "text" };
193
209
  import eagerTaskPrompt from "../prompts/system/eager-task.md" with { type: "text" };
194
210
  import eagerTodoPrompt from "../prompts/system/eager-todo.md" with { type: "text" };
@@ -262,12 +278,14 @@ import {
262
278
  SILENT_ABORT_MARKER,
263
279
  SKILL_PROMPT_MESSAGE_TYPE,
264
280
  stripImagesFromMessage,
281
+ USER_INTERRUPT_LABEL,
265
282
  } from "./messages";
266
283
  import type { SessionContext } from "./session-context";
267
284
  import { getLatestCompactionEntry, getRestorableSessionModels } from "./session-context";
268
285
  import { formatSessionDumpText } from "./session-dump-format";
269
286
  import type { BranchSummaryEntry, CompactionEntry, NewSessionOptions } from "./session-entries";
270
287
  import { EPHEMERAL_MODEL_CHANGE_ROLE } from "./session-entries";
288
+ import { formatSessionHistoryMarkdown } from "./session-history-format";
271
289
  import type { SessionManager } from "./session-manager";
272
290
  import type { ShakeMode, ShakeResult } from "./shake-types";
273
291
  import { ToolChoiceQueue } from "./tool-choice-queue";
@@ -457,6 +475,15 @@ export interface AgentSessionConfig {
457
475
  * so that credential sticky selection is consistent with the session's streaming calls.
458
476
  */
459
477
  providerSessionId?: string;
478
+ /**
479
+ * Hard-isolated read-only tools (read/search/find) for the advisor agent,
480
+ * pre-built in `createAgentSession` against a distinct `ToolSession` so the
481
+ * advisor's reads never share the primary's snapshot/seen-lines/conflict
482
+ * caches. Undefined when the advisor is disabled.
483
+ */
484
+ advisorReadOnlyTools?: AgentTool[];
485
+ /** Preloaded watchdog prompt content for the advisor. */
486
+ advisorWatchdogPrompt?: string;
460
487
  }
461
488
 
462
489
  /** Options for AgentSession.prompt() */
@@ -471,6 +498,12 @@ export interface PromptOptions {
471
498
  toolChoice?: ToolChoice;
472
499
  /** Send as developer/system message instead of user. Providers that support it use the developer role; others fall back to user. */
473
500
  synthetic?: boolean;
501
+ /** Marks this prompt as a deliberate user action (typed message, `.`/`c`
502
+ * continue). Clears advisor auto-resume suppression that a user interrupt set.
503
+ * Defaults to `!synthetic`; manual-continue is synthetic yet user-initiated, so
504
+ * it sets this explicitly. Agent-initiated synthetic prompts (auto-continue,
505
+ * plan re-prime, reminders) leave it unset and keep suppression latched. */
506
+ userInitiated?: boolean;
474
507
  /** Explicit billing/initiator attribution for the prompt. Defaults to user prompts as `user` and synthetic prompts as `agent`. */
475
508
  attribution?: MessageAttribution;
476
509
  /** Skip pre-send compaction checks for this prompt (internal use for maintenance flows). */
@@ -539,6 +572,28 @@ export interface SessionStats {
539
572
  cost: number;
540
573
  }
541
574
 
575
+ /** Advisor statistics for /advisor status command. */
576
+ export interface AdvisorStats {
577
+ configured: boolean;
578
+ active: boolean;
579
+ model?: Model;
580
+ contextWindow: number;
581
+ contextTokens: number;
582
+ tokens: {
583
+ input: number;
584
+ output: number;
585
+ cacheRead: number;
586
+ cacheWrite: number;
587
+ total: number;
588
+ };
589
+ cost: number;
590
+ messages: {
591
+ user: number;
592
+ assistant: number;
593
+ total: number;
594
+ };
595
+ }
596
+
542
597
  export interface FreshSessionResult {
543
598
  previousSessionId: string;
544
599
  sessionId: string;
@@ -894,6 +949,10 @@ function isDisplayableQueuedMessage(message: AgentMessage): boolean {
894
949
  return !(message.role === "custom" && message.display === false);
895
950
  }
896
951
 
952
+ function isAdvisorCard(message: AgentMessage): message is CustomMessage {
953
+ return message.role === "custom" && message.customType === "advisor";
954
+ }
955
+
897
956
  function queueChipText(message: AgentMessage): string {
898
957
  if (message.role === "custom") {
899
958
  return readQueueChipText(message.details) ?? queuedTextContent(message) ?? "";
@@ -941,9 +1000,20 @@ export class AgentSession {
941
1000
  #pendingNextTurnMessages: CustomMessage[] = [];
942
1001
  #scheduledHiddenNextTurnGeneration: number | undefined = undefined;
943
1002
  #queuedMessageDrainScheduled = false;
1003
+ /** Latched true when the user deliberately interrupts (USER_INTERRUPT_LABEL);
1004
+ * suppresses advisor concern/blocker auto-resume until the user next resumes.
1005
+ * Advisor advice is still recorded into the transcript, just not auto-run. */
1006
+ #advisorAutoResumeSuppressed = false;
944
1007
  #planModeState: PlanModeState | undefined;
945
1008
  #goalModeState: GoalModeState | undefined;
946
1009
  #goalRuntime: GoalRuntime;
1010
+ #advisorRuntime?: AdvisorRuntime;
1011
+ #advisorEnabled = false;
1012
+ /** The advisor's own agent, retained so `/dump advisor` can serialize its transcript. Undefined when no advisor is active. */
1013
+ #advisorAgent?: Agent;
1014
+ #advisorReadOnlyTools?: AgentTool[];
1015
+ #advisorWatchdogPrompt?: string;
1016
+ #advisorYieldQueueUnsubscribe?: () => void;
947
1017
  #goalTurnCounter = 0;
948
1018
  #planReferenceSent = false;
949
1019
  #planReferencePath = "local://PLAN.md";
@@ -1191,6 +1261,39 @@ export class AgentSession {
1191
1261
  this.#scheduleQueuedMessageDrain();
1192
1262
  }
1193
1263
 
1264
+ /** Remove advisor concern/blocker cards from the agent-core steer/follow-up
1265
+ * queues and return them. Used on a deliberate user interrupt so the post-abort
1266
+ * stranded-message drain cannot auto-resume the run on an advisor card that was
1267
+ * steered in just before the user stopped; real user follow-ups stay queued.
1268
+ * Synchronous and await-free so it runs before the abort path polls the queue. */
1269
+ #extractQueuedAdvisorCards(): CustomMessage[] {
1270
+ const steering = this.agent.peekSteeringQueue();
1271
+ const followUp = this.agent.peekFollowUpQueue();
1272
+ const cards = [...steering, ...followUp].filter(isAdvisorCard);
1273
+ if (cards.length === 0) return [];
1274
+ this.agent.replaceQueues(
1275
+ steering.filter(m => !isAdvisorCard(m)),
1276
+ followUp.filter(m => !isAdvisorCard(m)),
1277
+ );
1278
+ return cards;
1279
+ }
1280
+
1281
+ /** Record a suppressed advisor concern as visible, persisted advice without
1282
+ * triggering a turn. When the agent is idle (the normal post-interrupt case),
1283
+ * emit message_start/message_end like #flushPendingIrcAsides so
1284
+ * #handleAgentEvent renders it live (TUI/ACP) and persists it as a
1285
+ * CustomMessageEntry. While a turn is still tearing down (mid-abort), park it
1286
+ * hidden so abort's settle step replays it once idle — never appended into a
1287
+ * live streamMessage. */
1288
+ #preserveAdvisorCard(card: CustomMessage): void {
1289
+ if (this.isStreaming) {
1290
+ this.#pendingNextTurnMessages.push(card);
1291
+ return;
1292
+ }
1293
+ this.agent.emitExternalEvent({ type: "message_start", message: card });
1294
+ this.agent.emitExternalEvent({ type: "message_end", message: card });
1295
+ }
1296
+
1194
1297
  #resetInFlight(): void {
1195
1298
  this.#promptInFlightCount = 0;
1196
1299
  this.#releasePowerAssertion();
@@ -1234,6 +1337,8 @@ export class AgentSession {
1234
1337
  this.#customCommands = config.customCommands ?? [];
1235
1338
  this.#skillsSettings = config.skillsSettings;
1236
1339
  this.#modelRegistry = config.modelRegistry;
1340
+ this.#advisorReadOnlyTools = config.advisorReadOnlyTools;
1341
+ this.#advisorWatchdogPrompt = config.advisorWatchdogPrompt;
1237
1342
  this.#validateRetryFallbackChains();
1238
1343
  this.#toolRegistry = config.toolRegistry ?? new Map();
1239
1344
  this.#requestedToolNames = config.requestedToolNames;
@@ -1266,6 +1371,17 @@ export class AgentSession {
1266
1371
  };
1267
1372
  this.agent.setProviderResponseInterceptor(this.#onResponse);
1268
1373
  this.agent.setRawSseEventInterceptor(this.#onSseEvent);
1374
+ this.agent.setOnTurnEnd(async (messages, signal) => {
1375
+ if (signal?.aborted) return;
1376
+ if (this.#advisorRuntime && !this.#advisorRuntime.disposed) {
1377
+ this.#advisorRuntime.onTurnEnd(messages);
1378
+ const syncBacklog = this.settings.get("advisor.syncBacklog");
1379
+ if (syncBacklog !== "off") {
1380
+ const threshold = parseInt(syncBacklog, 10);
1381
+ await this.#advisorRuntime.waitForCatchup(30000, threshold, signal);
1382
+ }
1383
+ }
1384
+ });
1269
1385
  this.yieldQueue = new YieldQueue({
1270
1386
  isStreaming: () => this.isStreaming,
1271
1387
  injectIdle: async messages => {
@@ -1377,12 +1493,315 @@ export class AgentSession {
1377
1493
  },
1378
1494
  });
1379
1495
 
1496
+ this.#advisorEnabled = this.settings.get("advisor.enabled") as boolean;
1497
+ if (this.#advisorEnabled) this.#buildAdvisorRuntime();
1498
+
1380
1499
  // Always subscribe to agent events for internal handling
1381
1500
  // (session persistence, hooks, auto-compaction, retry logic)
1382
1501
  this.#unsubscribeAgent = this.agent.subscribe(this.#handleAgentEvent);
1383
1502
  // Re-evaluate append-only context mode when the setting changes at runtime.
1384
1503
  this.#unsubscribeAppendOnly = onAppendOnlyModeChanged(_value => this.#syncAppendOnlyContext(this.model));
1385
1504
  }
1505
+ // -------------------------------------------------------------------------
1506
+ // Advisor runtime lifecycle
1507
+ // -------------------------------------------------------------------------
1508
+ #buildAdvisorRuntime(seedToCurrent = false): boolean {
1509
+ if (this.#isDisposed) return false;
1510
+ if (this.#advisorRuntime) return true;
1511
+ if (!this.#advisorEnabled) return false;
1512
+ if (this.#agentKind !== "main" && !this.settings.get("advisor.subagents")) return false;
1513
+
1514
+ const advisorSel = resolveRoleSelection(
1515
+ ["advisor"],
1516
+ this.settings,
1517
+ this.#modelRegistry.getAvailable(),
1518
+ this.#modelRegistry,
1519
+ );
1520
+ if (!advisorSel) {
1521
+ logger.debug("advisor enabled but no model assigned to the 'advisor' role; advisor inactive");
1522
+ return false;
1523
+ }
1524
+
1525
+ // Concern and blocker interrupt the running agent through the steering
1526
+ // channel (aborting in-flight tools at the next steering boundary); when the
1527
+ // loop has already yielded, triggerTurn resumes it so the advice is acted on
1528
+ // immediately rather than waiting for the next user prompt. After a deliberate
1529
+ // user interrupt that auto-resume is suppressed: the concern is recorded as
1530
+ // visible advice and re-enters context only when the user resumes. A plain nit
1531
+ // rides the non-interrupting YieldQueue aside.
1532
+ const enqueueAdvice = (note: string, severity?: AdvisorSeverity) => {
1533
+ if (isInterruptingSeverity(severity)) {
1534
+ const notes: AdvisorNote[] = [{ note, severity }];
1535
+ const content = formatAdvisorBatchContent(notes);
1536
+ const details = { notes } satisfies AdvisorMessageDetails;
1537
+ if (this.#advisorAutoResumeSuppressed) {
1538
+ this.#preserveAdvisorCard({
1539
+ role: "custom",
1540
+ customType: "advisor",
1541
+ content,
1542
+ display: true,
1543
+ attribution: "agent",
1544
+ details,
1545
+ timestamp: Date.now(),
1546
+ });
1547
+ return;
1548
+ }
1549
+ void this.sendCustomMessage(
1550
+ { customType: "advisor", content, display: true, attribution: "agent", details },
1551
+ { deliverAs: "steer", triggerTurn: true },
1552
+ ).catch(err => logger.debug("advisor delivery failed", { err: String(err) }));
1553
+ return;
1554
+ }
1555
+ this.yieldQueue.enqueue("advisor", { note, severity });
1556
+ };
1557
+
1558
+ const adviseTool = new AdviseTool(enqueueAdvice);
1559
+ const advisorReadOnlyTools = this.#advisorReadOnlyTools ?? [];
1560
+
1561
+ const appendOnlyContext = new AppendOnlyContextManager();
1562
+ const advisorThinkingLevel = advisorSel.thinkingLevel ?? ThinkingLevel.Medium;
1563
+ const systemPrompt = [advisorSystemPrompt];
1564
+ if (this.#advisorWatchdogPrompt) {
1565
+ systemPrompt.push(this.#advisorWatchdogPrompt);
1566
+ }
1567
+ const advisorAgent = new Agent({
1568
+ initialState: {
1569
+ systemPrompt,
1570
+ model: advisorSel.model,
1571
+ thinkingLevel: toReasoningEffort(advisorThinkingLevel),
1572
+ tools: [adviseTool, ...advisorReadOnlyTools],
1573
+ },
1574
+ appendOnlyContext,
1575
+ sessionId: this.sessionId ? `${this.sessionId}-advisor` : undefined,
1576
+ getApiKey: async provider => {
1577
+ const key = await this.#modelRegistry.getApiKeyForProvider(
1578
+ provider,
1579
+ this.sessionId ? `${this.sessionId}-advisor` : undefined,
1580
+ );
1581
+ if (!key) throw new Error(`No API key for advisor provider "${provider}"`);
1582
+ return key;
1583
+ },
1584
+ intentTracing: false,
1585
+ });
1586
+ advisorAgent.setDisableReasoning(shouldDisableReasoning(advisorThinkingLevel));
1587
+
1588
+ const advisorAgentFacade: AdvisorAgent = {
1589
+ prompt: input => advisorAgent.prompt(input),
1590
+ abort: reason => advisorAgent.abort(reason),
1591
+ reset: () => {
1592
+ advisorAgent.reset();
1593
+ appendOnlyContext.log.clear();
1594
+ },
1595
+ state: advisorAgent.state,
1596
+ };
1597
+
1598
+ this.#advisorAgent = advisorAgent;
1599
+ this.#advisorRuntime = new AdvisorRuntime(advisorAgentFacade, {
1600
+ snapshotMessages: () => this.agent.state.messages,
1601
+ enqueueAdvice,
1602
+ maintainContext: incomingTokens => this.#maintainAdvisorContext(incomingTokens),
1603
+ });
1604
+ if (seedToCurrent) {
1605
+ this.#advisorRuntime.seedTo(this.agent.state.messages.length);
1606
+ }
1607
+
1608
+ // Batch non-blocking advisor notes into one injected custom message.
1609
+ this.#advisorYieldQueueUnsubscribe = this.yieldQueue.register<AdvisorNote>("advisor", {
1610
+ build: entries =>
1611
+ entries.length === 0
1612
+ ? null
1613
+ : ({
1614
+ role: "custom",
1615
+ customType: "advisor",
1616
+ display: true,
1617
+ attribution: "agent",
1618
+ timestamp: Date.now(),
1619
+ content: formatAdvisorBatchContent(entries),
1620
+ details: { notes: entries } satisfies AdvisorMessageDetails,
1621
+ } satisfies CustomMessage),
1622
+ skipIdleFlush: true,
1623
+ });
1624
+
1625
+ return true;
1626
+ }
1627
+
1628
+ #stopAdvisorRuntime(): void {
1629
+ if (this.#advisorRuntime) {
1630
+ this.#advisorRuntime.dispose();
1631
+ this.#advisorRuntime = undefined;
1632
+ }
1633
+ if (this.#advisorAgent) {
1634
+ this.#advisorAgent = undefined;
1635
+ }
1636
+ this.#advisorYieldQueueUnsubscribe?.();
1637
+ this.#advisorYieldQueueUnsubscribe = undefined;
1638
+ }
1639
+
1640
+ async #promoteAdvisorContextModel(currentModel: Model): Promise<boolean> {
1641
+ const promotionSettings = this.settings.getGroup("contextPromotion");
1642
+ if (!promotionSettings.enabled) return false;
1643
+ const contextWindow = currentModel.contextWindow ?? 0;
1644
+ if (contextWindow <= 0) return false;
1645
+ const targetModel = await this.#resolveContextPromotionTarget(currentModel, contextWindow);
1646
+ if (!targetModel) return false;
1647
+
1648
+ const advisorSel = resolveRoleSelection(
1649
+ ["advisor"],
1650
+ this.settings,
1651
+ this.#modelRegistry.getAvailable(),
1652
+ this.#modelRegistry,
1653
+ );
1654
+ const advisorThinkingLevel = advisorSel?.thinkingLevel ?? ThinkingLevel.Medium;
1655
+
1656
+ try {
1657
+ this.#advisorAgent?.setModel(targetModel);
1658
+ this.#advisorAgent?.setThinkingLevel(toReasoningEffort(advisorThinkingLevel));
1659
+ this.#advisorAgent?.setDisableReasoning(shouldDisableReasoning(advisorThinkingLevel));
1660
+ this.#advisorAgent?.appendOnlyContext?.invalidateForModelChange();
1661
+ logger.debug("Advisor context promotion switched model on overflow", {
1662
+ from: `${currentModel.provider}/${currentModel.id}`,
1663
+ to: `${targetModel.provider}/${targetModel.id}`,
1664
+ });
1665
+ return true;
1666
+ } catch (error) {
1667
+ logger.warn("Advisor context promotion failed", {
1668
+ from: `${currentModel.provider}/${currentModel.id}`,
1669
+ to: `${targetModel.provider}/${targetModel.id}`,
1670
+ error: String(error),
1671
+ });
1672
+ return false;
1673
+ }
1674
+ }
1675
+
1676
+ async #maintainAdvisorContext(incomingTokens: number): Promise<boolean> {
1677
+ const advisor = this.#advisorAgent;
1678
+ if (!advisor) return false;
1679
+
1680
+ const compactionSettings = this.settings.getGroup("compaction");
1681
+ if (compactionSettings.strategy === "off") return false;
1682
+ if (!compactionSettings.enabled) return false;
1683
+
1684
+ const advisorModel = advisor.state.model;
1685
+ const contextWindow = advisorModel.contextWindow ?? 0;
1686
+ if (contextWindow <= 0) return false;
1687
+
1688
+ const messages = advisor.state.messages;
1689
+ let contextTokens = incomingTokens;
1690
+ for (const message of messages) {
1691
+ contextTokens += estimateTokens(message);
1692
+ }
1693
+
1694
+ if (!shouldCompact(contextTokens, contextWindow, compactionSettings)) {
1695
+ return false;
1696
+ }
1697
+
1698
+ // 1. Try promotion first
1699
+ if (await this.#promoteAdvisorContextModel(advisorModel)) {
1700
+ // Promotion succeeded, check if new model has enough space
1701
+ const newModel = advisor.state.model;
1702
+ const newWindow = newModel.contextWindow ?? 0;
1703
+ if (newWindow > 0) {
1704
+ const stillNeedsCompaction = shouldCompact(contextTokens, newWindow, compactionSettings);
1705
+ if (!stillNeedsCompaction) return false;
1706
+ }
1707
+ }
1708
+
1709
+ // 2. Run compaction on advisor messages
1710
+ const pathEntries: SessionEntry[] = messages.map((message, i) => {
1711
+ const id = `msg-${i}`;
1712
+ const parentId = i > 0 ? `msg-${i - 1}` : null;
1713
+ const timestamp = String(message.timestamp || Date.now());
1714
+
1715
+ if (message.role === "compactionSummary") {
1716
+ return {
1717
+ type: "compaction",
1718
+ id,
1719
+ parentId,
1720
+ timestamp,
1721
+ summary: message.summary,
1722
+ shortSummary: message.shortSummary,
1723
+ firstKeptEntryId: (message as any).firstKeptEntryId || `msg-${i + 1}`,
1724
+ tokensBefore: message.tokensBefore,
1725
+ } satisfies CompactionEntry;
1726
+ }
1727
+
1728
+ return {
1729
+ type: "message",
1730
+ id,
1731
+ parentId,
1732
+ timestamp,
1733
+ message,
1734
+ } satisfies SessionMessageEntry;
1735
+ });
1736
+
1737
+ const preparation = prepareCompaction(pathEntries, compactionSettings);
1738
+ if (!preparation) {
1739
+ // Cannot prepare compaction, fallback to re-prime
1740
+ return true;
1741
+ }
1742
+
1743
+ const advisorCompactionThinkingLevel: ThinkingLevel | undefined = advisor.state.disableReasoning
1744
+ ? ThinkingLevel.Off
1745
+ : advisor.state.thinkingLevel;
1746
+
1747
+ // Advisor state is in-memory-only, so snapcompact's frame archive has no
1748
+ // stable SessionEntry preserveData slot to carry across future advisor
1749
+ // maintenance runs. Use an LLM summary even when the primary session is
1750
+ // configured for snapcompact.
1751
+ const availableModels = this.#modelRegistry.getAvailable();
1752
+ const candidates = this.#resolveCompactionModelCandidates(advisorModel, availableModels);
1753
+ if (candidates.length === 0) {
1754
+ // No compaction candidates, fallback to re-prime
1755
+ return true;
1756
+ }
1757
+
1758
+ let compactResult: CompactionResult | undefined;
1759
+ let lastError: unknown;
1760
+
1761
+ for (const candidate of candidates) {
1762
+ const apiKey = await this.#modelRegistry.getApiKey(
1763
+ candidate,
1764
+ this.sessionId ? `${this.sessionId}-advisor` : undefined,
1765
+ );
1766
+ if (!apiKey) continue;
1767
+
1768
+ try {
1769
+ compactResult = await compact(
1770
+ preparation,
1771
+ candidate,
1772
+ this.#modelRegistry.resolver(candidate, this.sessionId ? `${this.sessionId}-advisor` : undefined),
1773
+ undefined,
1774
+ undefined,
1775
+ {
1776
+ thinkingLevel: advisorCompactionThinkingLevel,
1777
+ convertToLlm: messages => this.#convertToLlmForSideRequest(messages),
1778
+ },
1779
+ );
1780
+ break;
1781
+ } catch (error) {
1782
+ lastError = error;
1783
+ }
1784
+ }
1785
+
1786
+ if (!compactResult) {
1787
+ logger.warn("Advisor compaction failed, falling back to re-prime", { error: String(lastError) });
1788
+ return true;
1789
+ }
1790
+
1791
+ const summary = compactResult.summary;
1792
+ const shortSummary = compactResult.shortSummary;
1793
+ const firstKeptEntryId = compactResult.firstKeptEntryId;
1794
+ const tokensBefore = compactResult.tokensBefore;
1795
+
1796
+ // Rebuild messages with the compaction summary
1797
+ const summaryMessage = {
1798
+ ...createCompactionSummaryMessage(summary, tokensBefore, new Date().toISOString(), shortSummary),
1799
+ firstKeptEntryId,
1800
+ } as CompactionSummaryMessage & { firstKeptEntryId?: string };
1801
+
1802
+ advisor.replaceMessages([summaryMessage, ...preparation.recentMessages]);
1803
+ return false;
1804
+ }
1386
1805
 
1387
1806
  /** Model registry for API key resolution and model discovery */
1388
1807
  get modelRegistry(): ModelRegistry {
@@ -3213,6 +3632,7 @@ export class AgentSession {
3213
3632
  this.#pendingIrcAsides = [];
3214
3633
  this.yieldQueue.clear();
3215
3634
  this.agent.setAsideMessageProvider(undefined);
3635
+ this.#stopAdvisorRuntime();
3216
3636
  this.#evalExecutionDisposing = true;
3217
3637
  }
3218
3638
 
@@ -4675,6 +5095,13 @@ export class AgentSession {
4675
5095
  // agent-initiated turns never trigger them.
4676
5096
  const keywordNotices = options?.synthetic ? [] : this.#createMagicKeywordNotices(expandedText);
4677
5097
 
5098
+ // A user-initiated prompt (typed message or the `.`/`c` continue shortcut)
5099
+ // re-enables advisor auto-resume that a prior user interrupt suppressed.
5100
+ // Agent-initiated synthetic prompts (auto-continue, plan, reminders) do not.
5101
+ if (options?.userInitiated ?? !options?.synthetic) {
5102
+ this.#advisorAutoResumeSuppressed = false;
5103
+ }
5104
+
4678
5105
  // If streaming, queue via steer() or followUp() based on option
4679
5106
  if (this.isStreaming) {
4680
5107
  if (!options?.streamingBehavior) {
@@ -5135,6 +5562,10 @@ export class AgentSession {
5135
5562
  images: ImageContent[] | undefined,
5136
5563
  mode: "steer" | "followUp",
5137
5564
  ): Promise<void> {
5565
+ // A queued user message (RPC/SDK/collab steer or follow-up, or a typed message
5566
+ // while streaming) is a deliberate resume; re-enable advisor auto-resume that
5567
+ // a user interrupt suppressed.
5568
+ this.#advisorAutoResumeSuppressed = false;
5138
5569
  const normalizedImages = await this.#normalizeImagesForModel(images);
5139
5570
  const content: (TextContent | ImageContent)[] = [{ type: "text", text }];
5140
5571
  if (normalizedImages?.length) {
@@ -5513,6 +5944,12 @@ export class AgentSession {
5513
5944
  * abort. Omit it for internal/lifecycle aborts.
5514
5945
  */
5515
5946
  async abort(options?: { goalReason?: "interrupted" | "internal"; reason?: string }): Promise<void> {
5947
+ const userInterrupt = options?.reason === USER_INTERRUPT_LABEL;
5948
+ if (userInterrupt) this.#advisorAutoResumeSuppressed = true;
5949
+ // Pull advisor concerns out of the steer/follow-up queues before any await so
5950
+ // the post-abort stranded-message drain can't auto-resume the run on them.
5951
+ // They are re-recorded as visible advice once the agent settles (below).
5952
+ const strandedAdvisorCards = userInterrupt ? this.#extractQueuedAdvisorCards() : [];
5516
5953
  // Session switch/compact paths disconnect first; explicit aborts should
5517
5954
  // leave any queued steer/follow-up visible for the user rather than
5518
5955
  // auto-starting a fresh turn during cleanup.
@@ -5541,6 +5978,19 @@ export class AgentSession {
5541
5978
  if (this.#toolChoiceQueue.hasInFlight) {
5542
5979
  this.#toolChoiceQueue.reject("aborted");
5543
5980
  }
5981
+ // Re-record advisor concerns the interrupt would otherwise strand, as
5982
+ // visible/persisted advice without triggering a turn (the agent is idle
5983
+ // now): cards steered into the queue before the user stopped, plus any
5984
+ // that arrived via enqueueAdvice mid-abort and were parked hidden in
5985
+ // #pendingNextTurnMessages while the turn was still tearing down. Other
5986
+ // deferred next-turn context (non-advisor) stays queued, in order.
5987
+ const parkedAdvisorCards = this.#pendingNextTurnMessages.filter(isAdvisorCard);
5988
+ if (parkedAdvisorCards.length > 0) {
5989
+ this.#pendingNextTurnMessages = this.#pendingNextTurnMessages.filter(m => !isAdvisorCard(m));
5990
+ }
5991
+ for (const card of [...strandedAdvisorCards, ...parkedAdvisorCards]) {
5992
+ this.#preserveAdvisorCard(card);
5993
+ }
5544
5994
  } finally {
5545
5995
  this.#abortInProgress = false;
5546
5996
  this.#drainStrandedQueuedMessages();
@@ -5617,6 +6067,7 @@ export class AgentSession {
5617
6067
  this.#todoReminderAwaitingProgress = false;
5618
6068
  this.#planReferenceSent = false;
5619
6069
  this.#planReferencePath = "local://PLAN.md";
6070
+ this.#advisorRuntime?.reset();
5620
6071
  this.#reconnectToAgent();
5621
6072
 
5622
6073
  // Emit session_switch event with reason "new" to hooks
@@ -6234,6 +6685,7 @@ export class AgentSession {
6234
6685
  await this.sessionManager.rewriteEntries();
6235
6686
  const sessionContext = this.buildDisplaySessionContext();
6236
6687
  this.agent.replaceMessages(sessionContext.messages);
6688
+ this.#advisorRuntime?.reset();
6237
6689
  this.#syncTodoPhasesFromBranch();
6238
6690
  this.#closeCodexProviderSessionsForHistoryRewrite();
6239
6691
  return result;
@@ -6263,9 +6715,9 @@ export class AgentSession {
6263
6715
  return undefined;
6264
6716
  }
6265
6717
 
6266
- await this.sessionManager.rewriteEntries();
6267
6718
  const sessionContext = this.buildDisplaySessionContext();
6268
6719
  this.agent.replaceMessages(sessionContext.messages);
6720
+ this.#advisorRuntime?.reset();
6269
6721
  this.#syncTodoPhasesFromBranch();
6270
6722
  this.#closeCodexProviderSessionsForHistoryRewrite();
6271
6723
  return result;
@@ -6316,6 +6768,7 @@ export class AgentSession {
6316
6768
  await this.sessionManager.rewriteEntries();
6317
6769
  const sessionContext = this.buildDisplaySessionContext();
6318
6770
  this.agent.replaceMessages(sessionContext.messages);
6771
+ this.#advisorRuntime?.reset();
6319
6772
  this.#closeCodexProviderSessionsForHistoryRewrite();
6320
6773
  return { removed };
6321
6774
  }
@@ -6366,6 +6819,7 @@ export class AgentSession {
6366
6819
  await this.sessionManager.rewriteEntries();
6367
6820
  const sessionContext = this.buildDisplaySessionContext();
6368
6821
  this.agent.replaceMessages(sessionContext.messages);
6822
+ this.#advisorRuntime?.reset();
6369
6823
  this.#closeCodexProviderSessionsForHistoryRewrite();
6370
6824
 
6371
6825
  return {
@@ -6582,6 +7036,7 @@ export class AgentSession {
6582
7036
  const newEntries = this.sessionManager.getEntries();
6583
7037
  const sessionContext = this.buildDisplaySessionContext();
6584
7038
  this.agent.replaceMessages(sessionContext.messages);
7039
+ this.#advisorRuntime?.reset();
6585
7040
  this.#syncTodoPhasesFromBranch();
6586
7041
  this.#closeCodexProviderSessionsForHistoryRewrite();
6587
7042
 
@@ -6801,6 +7256,7 @@ export class AgentSession {
6801
7256
  // Rebuild agent messages from session
6802
7257
  const sessionContext = this.buildDisplaySessionContext();
6803
7258
  this.agent.replaceMessages(sessionContext.messages);
7259
+ this.#advisorRuntime?.reset();
6804
7260
  this.#syncTodoPhasesFromBranch();
6805
7261
 
6806
7262
  return { document: handoffText, savedPath };
@@ -6838,8 +7294,11 @@ export class AgentSession {
6838
7294
  }
6839
7295
 
6840
7296
  let tokens = currentUsage.tokens;
6841
- if (currentEstimate.providerNonMessageTokens !== undefined) {
6842
- tokens += Math.max(0, computeNonMessageTokens(this) - currentEstimate.providerNonMessageTokens);
7297
+ const previousNonMessageTokens = currentEstimate.providerNonMessageTokens;
7298
+ if (previousNonMessageTokens !== undefined) {
7299
+ const currentNonMessageTokens = computeNonMessageTokens(this);
7300
+ const nonMessageTokenGrowth = Math.max(0, currentNonMessageTokens - previousNonMessageTokens);
7301
+ tokens += nonMessageTokenGrowth;
6843
7302
  }
6844
7303
  for (const message of messages) {
6845
7304
  tokens += estimateTokens(message);
@@ -7235,6 +7694,7 @@ export class AgentSession {
7235
7694
  }
7236
7695
  const safeCount = Math.max(0, Math.min(checkpointState.checkpointMessageCount, this.agent.state.messages.length));
7237
7696
  this.agent.replaceMessages(this.agent.state.messages.slice(0, safeCount));
7697
+ this.#advisorRuntime?.reset();
7238
7698
  try {
7239
7699
  this.sessionManager.branchWithSummary(checkpointState.checkpointEntryId, report, {
7240
7700
  startedAt: checkpointState.startedAt,
@@ -7843,6 +8303,10 @@ export class AgentSession {
7843
8303
  }
7844
8304
 
7845
8305
  #getCompactionModelCandidates(availableModels: Model[]): Model[] {
8306
+ return this.#resolveCompactionModelCandidates(this.model, availableModels);
8307
+ }
8308
+
8309
+ #resolveCompactionModelCandidates(preferredModel: Model | null | undefined, availableModels: Model[]): Model[] {
7846
8310
  const candidates: Model[] = [];
7847
8311
  const seen = new Set<string>();
7848
8312
 
@@ -7854,15 +8318,9 @@ export class AgentSession {
7854
8318
  candidates.push(model);
7855
8319
  };
7856
8320
 
7857
- const currentModel = this.model;
7858
- // Prefer the active session's model: it's what the user is actively using,
7859
- // and routing compaction to a different provider (e.g. an OpenAI default
7860
- // model while the chat is on Anthropic) changes provider-specific behavior
7861
- // like remote compaction endpoints. Role-based candidates only kick in
7862
- // as auth fallbacks when the current model has no usable credentials.
7863
- addCandidate(currentModel);
8321
+ addCandidate(preferredModel ?? undefined);
7864
8322
  for (const role of MODEL_ROLE_IDS) {
7865
- addCandidate(this.#resolveRoleModelFull(role, availableModels, currentModel).model);
8323
+ addCandidate(this.#resolveRoleModelFull(role, availableModels, preferredModel ?? undefined).model);
7866
8324
  }
7867
8325
 
7868
8326
  const sortedByContext = [...availableModels].sort((a, b) => (b.contextWindow ?? 0) - (a.contextWindow ?? 0));
@@ -8420,6 +8878,7 @@ export class AgentSession {
8420
8878
  const newEntries = this.sessionManager.getEntries();
8421
8879
  const sessionContext = this.buildDisplaySessionContext();
8422
8880
  this.agent.replaceMessages(sessionContext.messages);
8881
+ this.#advisorRuntime?.reset();
8423
8882
  this.#syncTodoPhasesFromBranch();
8424
8883
  this.#closeCodexProviderSessionsForHistoryRewrite();
8425
8884
 
@@ -8690,11 +9149,22 @@ export class AgentSession {
8690
9149
  if (isContextOverflow(message, contextWindow)) return false;
8691
9150
 
8692
9151
  if (this.#isClassifierRefusal(message)) return true;
9152
+ if (this.#streamInterruptedAfterObservableOutput(message)) return false;
8693
9153
  if (this.#isStaleOpenAIResponsesReplayError(message)) return true;
8694
9154
 
8695
9155
  const err = message.errorMessage;
8696
9156
  return this.#isTransientErrorMessage(err) || isUsageLimitError(err);
8697
9157
  }
9158
+ #streamInterruptedAfterObservableOutput(message: AssistantMessage): boolean {
9159
+ if (message.stopDetails?.type === STREAM_INTERRUPTED_AFTER_CONTENT_STOP_DETAIL) return true;
9160
+ for (const block of message.content) {
9161
+ if (block.type === "toolCall") return true;
9162
+ if (block.type === "text" && block.text.length > 0) return true;
9163
+ if (block.type === "thinking" && block.thinking.length > 0) return true;
9164
+ if (block.type === "redactedThinking" && block.data.length > 0) return true;
9165
+ }
9166
+ return false;
9167
+ }
8698
9168
 
8699
9169
  #isStaleOpenAIResponsesReplayError(message: AssistantMessage): boolean {
8700
9170
  const currentApi = this.model?.api;
@@ -10065,6 +10535,7 @@ export class AgentSession {
10065
10535
  this.#applyThinkingLevelToAgent(previousThinkingLevel);
10066
10536
  this.agent.serviceTier = previousServiceTier;
10067
10537
  this.#syncTodoPhasesFromBranch();
10538
+ this.#advisorRuntime?.reset();
10068
10539
  this.#reconnectToAgent();
10069
10540
  if (restoreMcpError) {
10070
10541
  throw restoreMcpError;
@@ -10146,6 +10617,7 @@ export class AgentSession {
10146
10617
 
10147
10618
  if (!skipConversationRestore) {
10148
10619
  this.agent.replaceMessages(sessionContext.messages);
10620
+ this.#advisorRuntime?.reset();
10149
10621
  this.#closeCodexProviderSessionsForHistoryRewrite();
10150
10622
  }
10151
10623
 
@@ -10312,6 +10784,7 @@ export class AgentSession {
10312
10784
  const displayContext = deobfuscateSessionContext(stateContext, this.#obfuscator);
10313
10785
  await this.#restoreMCPSelectionsForSessionContext(displayContext);
10314
10786
  this.agent.replaceMessages(displayContext.messages);
10787
+ this.#advisorRuntime?.reset();
10315
10788
  this.#syncTodoPhasesFromBranch();
10316
10789
  this.#closeCodexProviderSessionsForHistoryRewrite();
10317
10790
 
@@ -10808,7 +11281,10 @@ export class AgentSession {
10808
11281
  * Format the entire session as plain text for clipboard export.
10809
11282
  * Includes user messages, assistant text, thinking blocks, tool calls, and tool results.
10810
11283
  */
10811
- formatSessionAsText(): string {
11284
+ formatSessionAsText(options?: { compact?: boolean }): string {
11285
+ if (options?.compact) {
11286
+ return formatSessionHistoryMarkdown(this.messages);
11287
+ }
10812
11288
  return formatSessionDumpText({
10813
11289
  messages: this.messages,
10814
11290
  systemPrompt: this.agent.state.systemPrompt,
@@ -10818,6 +11294,184 @@ export class AgentSession {
10818
11294
  });
10819
11295
  }
10820
11296
 
11297
+ /**
11298
+ * Enable or disable the advisor for this session. The setting is overridden for the session,
11299
+ * and the runtime is started or stopped to match.
11300
+ *
11301
+ * @returns true when the advisor is actively running after the call.
11302
+ */
11303
+ setAdvisorEnabled(enabled: boolean): boolean {
11304
+ this.#advisorEnabled = enabled;
11305
+ if (enabled) {
11306
+ return this.#buildAdvisorRuntime(true);
11307
+ }
11308
+ this.#stopAdvisorRuntime();
11309
+ return false;
11310
+ }
11311
+
11312
+ /**
11313
+ * Toggle the advisor setting and start/stop the runtime accordingly.
11314
+ *
11315
+ * @returns true when the advisor is actively running after the call.
11316
+ */
11317
+ toggleAdvisorEnabled(): boolean {
11318
+ return this.setAdvisorEnabled(!this.#advisorEnabled);
11319
+ }
11320
+
11321
+ /**
11322
+ * Whether the advisor setting is enabled for this session.
11323
+ */
11324
+ isAdvisorEnabled(): boolean {
11325
+ return this.#advisorEnabled;
11326
+ }
11327
+
11328
+ /**
11329
+ * Whether a live advisor agent is attached to this session. True only when
11330
+ * `advisor.enabled` is set AND a model resolved for the `advisor` role AND
11331
+ * the advisor applies to this agent kind — i.e. the actual runtime exists,
11332
+ * not merely the setting. Drives the status-line badge and `/dump advisor`.
11333
+ */
11334
+ isAdvisorActive(): boolean {
11335
+ return this.#advisorAgent !== undefined;
11336
+ }
11337
+
11338
+ /**
11339
+ * Return structured advisor stats for the status command and TUI panel.
11340
+ */
11341
+ getAdvisorStats(): AdvisorStats {
11342
+ const configured = this.#advisorEnabled;
11343
+ const advisor = this.#advisorAgent;
11344
+ if (!advisor) {
11345
+ return {
11346
+ configured,
11347
+ active: false,
11348
+ contextWindow: 0,
11349
+ contextTokens: 0,
11350
+ tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
11351
+ cost: 0,
11352
+ messages: { user: 0, assistant: 0, total: 0 },
11353
+ };
11354
+ }
11355
+ const model = advisor.state.model;
11356
+ const messages = advisor.state.messages;
11357
+ const contextTokens = this.#estimateAdvisorContextTokens(messages);
11358
+ let input = 0;
11359
+ let output = 0;
11360
+ let cacheRead = 0;
11361
+ let cacheWrite = 0;
11362
+ let cost = 0;
11363
+ let user = 0;
11364
+ let assistant = 0;
11365
+ for (const message of messages) {
11366
+ if (message.role === "user") user++;
11367
+ if (message.role === "assistant") {
11368
+ assistant++;
11369
+ const assistantMsg = message as AssistantMessage;
11370
+ input += assistantMsg.usage.input;
11371
+ output += assistantMsg.usage.output;
11372
+ cacheRead += assistantMsg.usage.cacheRead;
11373
+ cacheWrite += assistantMsg.usage.cacheWrite;
11374
+ cost += assistantMsg.usage.cost.total;
11375
+ }
11376
+ }
11377
+ return {
11378
+ configured,
11379
+ active: true,
11380
+ model,
11381
+ contextWindow: model.contextWindow ?? 0,
11382
+ contextTokens,
11383
+ tokens: {
11384
+ input,
11385
+ output,
11386
+ cacheRead,
11387
+ cacheWrite,
11388
+ total: input + output + cacheRead + cacheWrite,
11389
+ },
11390
+ cost,
11391
+ messages: { user, assistant, total: messages.length },
11392
+ };
11393
+ }
11394
+
11395
+ /**
11396
+ * Format a concise advisor status line for ACP/text output.
11397
+ */
11398
+ formatAdvisorStatus(): string {
11399
+ const stats = this.getAdvisorStats();
11400
+ if (!stats.active) {
11401
+ return stats.configured
11402
+ ? "Advisor setting is enabled, but no model is assigned to the 'advisor' role."
11403
+ : "Advisor is disabled.";
11404
+ }
11405
+ const model = stats.model!;
11406
+ const contextLine =
11407
+ stats.contextWindow > 0
11408
+ ? `Context: ${stats.contextTokens.toLocaleString()} / ${stats.contextWindow.toLocaleString()} tokens (${Math.round((stats.contextTokens / stats.contextWindow) * 100)}%)`
11409
+ : `Context: ${stats.contextTokens.toLocaleString()} tokens`;
11410
+ const spendParts = [
11411
+ `${stats.tokens.input.toLocaleString()} input`,
11412
+ `${stats.tokens.output.toLocaleString()} output`,
11413
+ ];
11414
+ if (stats.tokens.cacheRead > 0) spendParts.push(`${stats.tokens.cacheRead.toLocaleString()} cache read`);
11415
+ if (stats.tokens.cacheWrite > 0) spendParts.push(`${stats.tokens.cacheWrite.toLocaleString()} cache write`);
11416
+ const spendLine = `Spend: ${spendParts.join(", ")}, $${stats.cost.toFixed(4)}`;
11417
+ return `Advisor is enabled (${model.provider}/${model.id}). ${contextLine}. ${spendLine}.`;
11418
+ }
11419
+
11420
+ /**
11421
+ * Estimate the advisor's current context tokens. When the advisor has a
11422
+ * recent non-aborted assistant message with usage, use that prompt's token
11423
+ * count and add a trailing estimate for messages after it. Otherwise estimate
11424
+ * every message.
11425
+ */
11426
+ #estimateAdvisorContextTokens(messages: AgentMessage[]): number {
11427
+ let lastUsageIndex: number | null = null;
11428
+ let lastUsage: AssistantMessage["usage"] | undefined;
11429
+ for (let i = messages.length - 1; i >= 0; i--) {
11430
+ const msg = messages[i];
11431
+ if (msg.role === "assistant") {
11432
+ const assistantMsg = msg as AssistantMessage;
11433
+ if (assistantMsg.stopReason !== "aborted" && assistantMsg.stopReason !== "error" && assistantMsg.usage) {
11434
+ lastUsage = assistantMsg.usage;
11435
+ lastUsageIndex = i;
11436
+ break;
11437
+ }
11438
+ }
11439
+ }
11440
+ if (!lastUsage || lastUsageIndex === null) {
11441
+ let estimated = 0;
11442
+ for (const message of messages) {
11443
+ estimated += estimateTokens(message);
11444
+ }
11445
+ return estimated;
11446
+ }
11447
+ let trailingTokens = 0;
11448
+ for (let i = lastUsageIndex + 1; i < messages.length; i++) {
11449
+ trailingTokens += estimateTokens(messages[i]);
11450
+ }
11451
+ return calculatePromptTokens(lastUsage) + trailingTokens;
11452
+ }
11453
+
11454
+ /**
11455
+ * Format the advisor agent's own transcript (its system prompt, config,
11456
+ * tools, and the markdown deltas it received plus its thinking/advise/read
11457
+ * calls) as plain text — the advisor-side equivalent of
11458
+ * {@link formatSessionAsText}. Returns null when no advisor is active.
11459
+ */
11460
+ formatAdvisorHistoryAsText(options?: { compact?: boolean }): string | null {
11461
+ const advisor = this.#advisorAgent;
11462
+ if (!advisor) return null;
11463
+ if (options?.compact) {
11464
+ return formatSessionHistoryMarkdown(advisor.state.messages);
11465
+ }
11466
+ return formatSessionDumpText({
11467
+ messages: advisor.state.messages,
11468
+ systemPrompt: advisor.state.systemPrompt,
11469
+ model: advisor.state.model,
11470
+ thinkingLevel: advisor.state.thinkingLevel,
11471
+ tools: advisor.state.tools,
11472
+ });
11473
+ }
11474
+
10821
11475
  // =========================================================================
10822
11476
  // Extension System
10823
11477
  // =========================================================================