@oh-my-pi/pi-coding-agent 15.13.2 → 16.0.0

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 (84) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/dist/cli.js +587 -499
  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 +75 -5
  10. package/dist/types/eval/js/context-manager.d.ts +15 -0
  11. package/dist/types/modes/components/advisor-message.d.ts +9 -0
  12. package/dist/types/modes/components/assistant-message.d.ts +1 -0
  13. package/dist/types/modes/controllers/command-controller.d.ts +3 -1
  14. package/dist/types/modes/interactive-mode.d.ts +4 -1
  15. package/dist/types/modes/types.d.ts +9 -1
  16. package/dist/types/sdk.d.ts +3 -3
  17. package/dist/types/session/agent-session.d.ts +71 -2
  18. package/dist/types/session/session-history-format.d.ts +4 -0
  19. package/dist/types/session/unexpected-stop-classifier.d.ts +13 -0
  20. package/dist/types/session/yield-queue.d.ts +2 -0
  21. package/dist/types/stt/asr-client.d.ts +1 -1
  22. package/dist/types/tiny/title-client.d.ts +1 -1
  23. package/dist/types/tools/job.d.ts +1 -0
  24. package/dist/types/tools/path-utils.d.ts +1 -0
  25. package/dist/types/tools/report-tool-issue.d.ts +0 -1
  26. package/dist/types/tts/tts-client.d.ts +1 -1
  27. package/dist/types/utils/thinking-display.d.ts +1 -17
  28. package/package.json +13 -13
  29. package/src/advisor/__tests__/advisor.test.ts +586 -0
  30. package/src/advisor/advise-tool.ts +87 -0
  31. package/src/advisor/index.ts +3 -0
  32. package/src/advisor/runtime.ts +248 -0
  33. package/src/advisor/watchdog.ts +83 -0
  34. package/src/cli.ts +25 -12
  35. package/src/config/model-registry.ts +6 -2
  36. package/src/config/model-roles.ts +13 -1
  37. package/src/config/settings-schema.ts +67 -5
  38. package/src/eval/__tests__/agent-bridge.test.ts +106 -46
  39. package/src/eval/__tests__/js-context-manager.test.ts +12 -2
  40. package/src/eval/js/context-manager.ts +40 -3
  41. package/src/eval/js/worker-entry.ts +7 -0
  42. package/src/export/html/template.js +18 -22
  43. package/src/internal-urls/docs-index.generated.ts +8 -5
  44. package/src/main.ts +19 -5
  45. package/src/modes/acp/acp-agent.ts +2 -2
  46. package/src/modes/acp/acp-event-mapper.ts +2 -2
  47. package/src/modes/components/advisor-message.ts +99 -0
  48. package/src/modes/components/agent-hub.ts +38 -7
  49. package/src/modes/components/assistant-message.ts +110 -15
  50. package/src/modes/components/snapcompact-shape-preview-doc.md +2 -2
  51. package/src/modes/components/snapcompact-shape-preview.ts +2 -2
  52. package/src/modes/components/status-line/segments.ts +20 -7
  53. package/src/modes/components/tree-selector.ts +3 -2
  54. package/src/modes/controllers/command-controller.ts +69 -2
  55. package/src/modes/controllers/event-controller.ts +3 -3
  56. package/src/modes/controllers/input-controller.ts +7 -1
  57. package/src/modes/controllers/streaming-reveal.ts +4 -4
  58. package/src/modes/interactive-mode.ts +14 -2
  59. package/src/modes/types.ts +9 -1
  60. package/src/modes/utils/ui-helpers.ts +12 -3
  61. package/src/prompts/advisor/advise-tool.md +1 -0
  62. package/src/prompts/advisor/system.md +31 -0
  63. package/src/prompts/agents/oracle.md +0 -1
  64. package/src/prompts/agents/reviewer.md +0 -1
  65. package/src/prompts/system/unexpected-stop-classifier.md +17 -0
  66. package/src/prompts/system/unexpected-stop-retry.md +4 -0
  67. package/src/sdk.ts +52 -13
  68. package/src/session/agent-session.ts +722 -21
  69. package/src/session/session-dump-format.ts +15 -142
  70. package/src/session/session-history-format.ts +30 -11
  71. package/src/session/unexpected-stop-classifier.ts +129 -0
  72. package/src/session/yield-queue.ts +5 -1
  73. package/src/slash-commands/builtin-registry.ts +102 -4
  74. package/src/stt/asr-client.ts +1 -1
  75. package/src/system-prompt.ts +1 -1
  76. package/src/tiny/title-client.ts +1 -1
  77. package/src/tools/browser/tab-supervisor.ts +1 -1
  78. package/src/tools/browser/tab-worker-entry.ts +12 -4
  79. package/src/tools/job.ts +1 -0
  80. package/src/tools/path-utils.ts +33 -2
  81. package/src/tools/report-tool-issue.ts +2 -7
  82. package/src/tts/tts-client.ts +1 -1
  83. package/src/utils/thinking-display.ts +8 -34
  84. package/src/web/scrapers/docs-rs.ts +2 -3
@@ -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,6 +30,7 @@ import {
30
30
  type AgentTool,
31
31
  AppendOnlyContextManager,
32
32
  type AsideMessage,
33
+ type CompactionSummaryMessage,
33
34
  resolveTelemetry,
34
35
  ThinkingLevel,
35
36
  } from "@oh-my-pi/pi-agent-core";
@@ -54,6 +55,8 @@ import {
54
55
  generateHandoff,
55
56
  prepareCompaction,
56
57
  resolveThresholdTokens,
58
+ type SessionEntry,
59
+ type SessionMessageEntry,
57
60
  type ShakeConfig,
58
61
  type ShakeRegion,
59
62
  type SummaryOptions,
@@ -114,6 +117,16 @@ import {
114
117
  Snowflake,
115
118
  } from "@oh-my-pi/pi-utils";
116
119
  import * as snapcompact from "@oh-my-pi/snapcompact";
120
+ import {
121
+ AdviseTool,
122
+ type AdvisorAgent,
123
+ type AdvisorMessageDetails,
124
+ type AdvisorNote,
125
+ AdvisorRuntime,
126
+ type AdvisorSeverity,
127
+ formatAdvisorBatchContent,
128
+ isInterruptingSeverity,
129
+ } from "../advisor";
117
130
  import { type AsyncJob, type AsyncJobDeliveryState, AsyncJobManager } from "../async";
118
131
  import { classifyDifficulty } from "../auto-thinking/classifier";
119
132
  import { reset as resetCapabilities } from "../capability";
@@ -129,6 +142,7 @@ import {
129
142
  parseModelString,
130
143
  type ResolvedModelRoleValue,
131
144
  resolveModelRoleValue,
145
+ resolveRoleSelection,
132
146
  } from "../config/model-resolver";
133
147
  import { MODEL_ROLE_IDS } from "../config/model-roles";
134
148
  import { expandPromptTemplate, type PromptTemplate } from "../config/prompt-templates";
@@ -189,6 +203,7 @@ import { computeNonMessageTokens } from "../modes/utils/context-usage";
189
203
  import { containsWorkflow, WORKFLOW_NOTICE } from "../modes/workflow";
190
204
  import { createPlanReadMatcher } from "../plan-mode/plan-protection";
191
205
  import type { PlanModeState } from "../plan-mode/state";
206
+ import advisorSystemPrompt from "../prompts/advisor/system.md" with { type: "text" };
192
207
  import autoContinuePrompt from "../prompts/system/auto-continue.md" with { type: "text" };
193
208
  import eagerTaskPrompt from "../prompts/system/eager-task.md" with { type: "text" };
194
209
  import eagerTodoPrompt from "../prompts/system/eager-todo.md" with { type: "text" };
@@ -202,6 +217,7 @@ import planModeToolDecisionReminderPrompt from "../prompts/system/plan-mode-tool
202
217
  };
203
218
  import ttsrInterruptTemplate from "../prompts/system/ttsr-interrupt.md" with { type: "text" };
204
219
  import ttsrToolReminderTemplate from "../prompts/system/ttsr-tool-reminder.md" with { type: "text" };
220
+ import unexpectedStopRetryTemplate from "../prompts/system/unexpected-stop-retry.md" with { type: "text" };
205
221
  import {
206
222
  deobfuscateSessionContext,
207
223
  obfuscateProviderContext,
@@ -267,9 +283,11 @@ import { getLatestCompactionEntry, getRestorableSessionModels } from "./session-
267
283
  import { formatSessionDumpText } from "./session-dump-format";
268
284
  import type { BranchSummaryEntry, CompactionEntry, NewSessionOptions } from "./session-entries";
269
285
  import { EPHEMERAL_MODEL_CHANGE_ROLE } from "./session-entries";
286
+ import { formatSessionHistoryMarkdown } from "./session-history-format";
270
287
  import type { SessionManager } from "./session-manager";
271
288
  import type { ShakeMode, ShakeResult } from "./shake-types";
272
289
  import { ToolChoiceQueue } from "./tool-choice-queue";
290
+ import { classifyUnexpectedStop, isUnexpectedStopCandidate } from "./unexpected-stop-classifier";
273
291
  import { YieldQueue } from "./yield-queue";
274
292
 
275
293
  /** Session-specific events that extend the core AgentEvent */
@@ -308,14 +326,16 @@ export type AgentSessionEvent =
308
326
  resolved?: Effort;
309
327
  }
310
328
  | { type: "goal_updated"; goal: Goal | null; state?: GoalModeState };
311
-
312
329
  /** Listener function for agent session events */
313
330
  export type AgentSessionEventListener = (event: AgentSessionEvent) => void;
314
- export type CommandMetadataChangedListener = () => void | Promise<void>;
315
- export type AsyncJobSnapshotItem = Pick<AsyncJob, "id" | "type" | "status" | "label" | "startTime">;
316
331
 
332
+ const UNEXPECTED_STOP_MAX_RETRIES = 3;
333
+ const UNEXPECTED_STOP_TIMEOUT_MS = 4000;
317
334
  const EMPTY_STOP_MAX_RETRIES = 3;
318
335
  const RETRY_BACKOFF_MAX_DELAY_MS = 8_000;
336
+ export type CommandMetadataChangedListener = () => void | Promise<void>;
337
+ export type AsyncJobSnapshotItem = Pick<AsyncJob, "id" | "type" | "status" | "label" | "startTime">;
338
+
319
339
  const RETRY_BACKOFF_JITTER_RATIO = 0.25;
320
340
  /**
321
341
  * Hysteresis band for the post-shake "did we actually create headroom?" check.
@@ -453,6 +473,15 @@ export interface AgentSessionConfig {
453
473
  * so that credential sticky selection is consistent with the session's streaming calls.
454
474
  */
455
475
  providerSessionId?: string;
476
+ /**
477
+ * Hard-isolated read-only tools (read/search/find) for the advisor agent,
478
+ * pre-built in `createAgentSession` against a distinct `ToolSession` so the
479
+ * advisor's reads never share the primary's snapshot/seen-lines/conflict
480
+ * caches. Undefined when the advisor is disabled.
481
+ */
482
+ advisorReadOnlyTools?: AgentTool[];
483
+ /** Preloaded watchdog prompt content for the advisor. */
484
+ advisorWatchdogPrompt?: string;
456
485
  }
457
486
 
458
487
  /** Options for AgentSession.prompt() */
@@ -535,6 +564,28 @@ export interface SessionStats {
535
564
  cost: number;
536
565
  }
537
566
 
567
+ /** Advisor statistics for /advisor status command. */
568
+ export interface AdvisorStats {
569
+ configured: boolean;
570
+ active: boolean;
571
+ model?: Model;
572
+ contextWindow: number;
573
+ contextTokens: number;
574
+ tokens: {
575
+ input: number;
576
+ output: number;
577
+ cacheRead: number;
578
+ cacheWrite: number;
579
+ total: number;
580
+ };
581
+ cost: number;
582
+ messages: {
583
+ user: number;
584
+ assistant: number;
585
+ total: number;
586
+ };
587
+ }
588
+
538
589
  export interface FreshSessionResult {
539
590
  previousSessionId: string;
540
591
  sessionId: string;
@@ -940,6 +991,12 @@ export class AgentSession {
940
991
  #planModeState: PlanModeState | undefined;
941
992
  #goalModeState: GoalModeState | undefined;
942
993
  #goalRuntime: GoalRuntime;
994
+ #advisorRuntime?: AdvisorRuntime;
995
+ /** The advisor's own agent, retained so `/dump advisor` can serialize its transcript. Undefined when no advisor is active. */
996
+ #advisorAgent?: Agent;
997
+ #advisorReadOnlyTools?: AgentTool[];
998
+ #advisorWatchdogPrompt?: string;
999
+ #advisorYieldQueueUnsubscribe?: () => void;
943
1000
  #goalTurnCounter = 0;
944
1001
  #planReferenceSent = false;
945
1002
  #planReferencePath = "local://PLAN.md";
@@ -1106,15 +1163,23 @@ export class AgentSession {
1106
1163
  // `#emit(event)` that reaches external subscribers (rpc-mode stdout, ACP bridge,
1107
1164
  // Cursor exec, TUI listeners) is held back. Without this, a client that resumes
1108
1165
  // on `agent_end` can fire its next `prompt` before #promptWithMessage's finally
1109
- // has decremented #promptInFlightCount, hitting AgentBusyError. Flushed from
1110
- // both #endInFlight (normal) and #resetInFlight (abort).
1166
+ #emptyStopRetryCount = 0;
1167
+ #unexpectedStopRetryCount = 0;
1168
+ #promptGeneration = 0;
1111
1169
  #pendingAgentEndEmit: AgentSessionEvent | undefined;
1170
+ #pendingProviderRequestNonMessageTokens: number | undefined = undefined;
1171
+ #lastProviderUsageNonMessage:
1172
+ | {
1173
+ provider: AssistantMessage["provider"];
1174
+ model: AssistantMessage["model"];
1175
+ timestamp: AssistantMessage["timestamp"];
1176
+ tokens: number;
1177
+ }
1178
+ | undefined;
1112
1179
  #obfuscator: SecretObfuscator | undefined;
1113
1180
  #checkpointState: CheckpointState | undefined = undefined;
1114
1181
  #pendingRewindReport: string | undefined = undefined;
1115
1182
  #lastSuccessfulYieldToolCallId: string | undefined = undefined;
1116
- #emptyStopRetryCount = 0;
1117
- #promptGeneration = 0;
1118
1183
  #providerSessionState = new Map<string, ProviderSessionState>();
1119
1184
  #hindsightSessionState: HindsightSessionState | undefined = undefined;
1120
1185
  readonly rawSseDebugBuffer: RawSseDebugBuffer;
@@ -1222,6 +1287,8 @@ export class AgentSession {
1222
1287
  this.#customCommands = config.customCommands ?? [];
1223
1288
  this.#skillsSettings = config.skillsSettings;
1224
1289
  this.#modelRegistry = config.modelRegistry;
1290
+ this.#advisorReadOnlyTools = config.advisorReadOnlyTools;
1291
+ this.#advisorWatchdogPrompt = config.advisorWatchdogPrompt;
1225
1292
  this.#validateRetryFallbackChains();
1226
1293
  this.#toolRegistry = config.toolRegistry ?? new Map();
1227
1294
  this.#requestedToolNames = config.requestedToolNames;
@@ -1254,6 +1321,17 @@ export class AgentSession {
1254
1321
  };
1255
1322
  this.agent.setProviderResponseInterceptor(this.#onResponse);
1256
1323
  this.agent.setRawSseEventInterceptor(this.#onSseEvent);
1324
+ this.agent.setOnTurnEnd(async (messages, signal) => {
1325
+ if (signal?.aborted) return;
1326
+ if (this.#advisorRuntime && !this.#advisorRuntime.disposed) {
1327
+ this.#advisorRuntime.onTurnEnd(messages);
1328
+ const syncBacklog = this.settings.get("advisor.syncBacklog");
1329
+ if (syncBacklog !== "off") {
1330
+ const threshold = parseInt(syncBacklog, 10);
1331
+ await this.#advisorRuntime.waitForCatchup(30000, threshold, signal);
1332
+ }
1333
+ }
1334
+ });
1257
1335
  this.yieldQueue = new YieldQueue({
1258
1336
  isStreaming: () => this.isStreaming,
1259
1337
  injectIdle: async messages => {
@@ -1365,12 +1443,304 @@ export class AgentSession {
1365
1443
  },
1366
1444
  });
1367
1445
 
1446
+ if (this.settings.get("advisor.enabled")) this.#buildAdvisorRuntime();
1447
+
1368
1448
  // Always subscribe to agent events for internal handling
1369
1449
  // (session persistence, hooks, auto-compaction, retry logic)
1370
1450
  this.#unsubscribeAgent = this.agent.subscribe(this.#handleAgentEvent);
1371
1451
  // Re-evaluate append-only context mode when the setting changes at runtime.
1372
1452
  this.#unsubscribeAppendOnly = onAppendOnlyModeChanged(_value => this.#syncAppendOnlyContext(this.model));
1373
1453
  }
1454
+ // -------------------------------------------------------------------------
1455
+ // Advisor runtime lifecycle
1456
+ // -------------------------------------------------------------------------
1457
+ #buildAdvisorRuntime(seedToCurrent = false): boolean {
1458
+ if (this.#isDisposed) return false;
1459
+ if (this.#advisorRuntime) return true;
1460
+ if (!this.settings.get("advisor.enabled")) return false;
1461
+ if (this.#agentKind !== "main" && !this.settings.get("advisor.subagents")) return false;
1462
+
1463
+ const advisorSel = resolveRoleSelection(
1464
+ ["advisor"],
1465
+ this.settings,
1466
+ this.#modelRegistry.getAvailable(),
1467
+ this.#modelRegistry,
1468
+ );
1469
+ if (!advisorSel) {
1470
+ logger.debug("advisor enabled but no model assigned to the 'advisor' role; advisor inactive");
1471
+ return false;
1472
+ }
1473
+
1474
+ // Concern and blocker interrupt the running agent through the steering
1475
+ // channel (aborting in-flight tools at the next steering boundary); when
1476
+ // the loop has already yielded, triggerTurn resumes it so the advice is
1477
+ // acted on immediately rather than waiting for the next user prompt. A
1478
+ // plain nit rides the non-interrupting YieldQueue aside.
1479
+ const enqueueAdvice = (note: string, severity?: AdvisorSeverity) => {
1480
+ if (isInterruptingSeverity(severity)) {
1481
+ const notes: AdvisorNote[] = [{ note, severity }];
1482
+ void this.sendCustomMessage(
1483
+ {
1484
+ customType: "advisor",
1485
+ content: formatAdvisorBatchContent(notes),
1486
+ display: true,
1487
+ attribution: "agent",
1488
+ details: { notes } satisfies AdvisorMessageDetails,
1489
+ },
1490
+ { deliverAs: "steer", triggerTurn: true },
1491
+ ).catch(err => logger.debug("advisor steer failed", { err: String(err) }));
1492
+ return;
1493
+ }
1494
+ this.yieldQueue.enqueue("advisor", { note, severity });
1495
+ };
1496
+
1497
+ const adviseTool = new AdviseTool(enqueueAdvice);
1498
+ const advisorReadOnlyTools = this.#advisorReadOnlyTools ?? [];
1499
+
1500
+ const appendOnlyContext = new AppendOnlyContextManager();
1501
+ const advisorThinkingLevel = advisorSel.thinkingLevel ?? ThinkingLevel.Medium;
1502
+ const systemPrompt = [advisorSystemPrompt];
1503
+ if (this.#advisorWatchdogPrompt) {
1504
+ systemPrompt.push(this.#advisorWatchdogPrompt);
1505
+ }
1506
+ const advisorAgent = new Agent({
1507
+ initialState: {
1508
+ systemPrompt,
1509
+ model: advisorSel.model,
1510
+ thinkingLevel: toReasoningEffort(advisorThinkingLevel),
1511
+ tools: [adviseTool, ...advisorReadOnlyTools],
1512
+ },
1513
+ appendOnlyContext,
1514
+ sessionId: this.sessionId ? `${this.sessionId}-advisor` : undefined,
1515
+ getApiKey: async provider => {
1516
+ const key = await this.#modelRegistry.getApiKeyForProvider(
1517
+ provider,
1518
+ this.sessionId ? `${this.sessionId}-advisor` : undefined,
1519
+ );
1520
+ if (!key) throw new Error(`No API key for advisor provider "${provider}"`);
1521
+ return key;
1522
+ },
1523
+ intentTracing: false,
1524
+ });
1525
+ advisorAgent.setDisableReasoning(shouldDisableReasoning(advisorThinkingLevel));
1526
+
1527
+ const advisorAgentFacade: AdvisorAgent = {
1528
+ prompt: input => advisorAgent.prompt(input),
1529
+ abort: reason => advisorAgent.abort(reason),
1530
+ reset: () => {
1531
+ advisorAgent.reset();
1532
+ appendOnlyContext.log.clear();
1533
+ },
1534
+ state: advisorAgent.state,
1535
+ };
1536
+
1537
+ this.#advisorAgent = advisorAgent;
1538
+ this.#advisorRuntime = new AdvisorRuntime(advisorAgentFacade, {
1539
+ snapshotMessages: () => this.agent.state.messages,
1540
+ enqueueAdvice,
1541
+ maintainContext: incomingTokens => this.#maintainAdvisorContext(incomingTokens),
1542
+ });
1543
+ if (seedToCurrent) {
1544
+ this.#advisorRuntime.seedTo(this.agent.state.messages.length);
1545
+ }
1546
+
1547
+ // Batch non-blocking advisor notes into one injected custom message.
1548
+ this.#advisorYieldQueueUnsubscribe = this.yieldQueue.register<AdvisorNote>("advisor", {
1549
+ build: entries =>
1550
+ entries.length === 0
1551
+ ? null
1552
+ : ({
1553
+ role: "custom",
1554
+ customType: "advisor",
1555
+ display: true,
1556
+ attribution: "agent",
1557
+ timestamp: Date.now(),
1558
+ content: formatAdvisorBatchContent(entries),
1559
+ details: { notes: entries } satisfies AdvisorMessageDetails,
1560
+ } satisfies CustomMessage),
1561
+ skipIdleFlush: true,
1562
+ });
1563
+
1564
+ return true;
1565
+ }
1566
+
1567
+ #stopAdvisorRuntime(): void {
1568
+ if (this.#advisorRuntime) {
1569
+ this.#advisorRuntime.dispose();
1570
+ this.#advisorRuntime = undefined;
1571
+ }
1572
+ if (this.#advisorAgent) {
1573
+ this.#advisorAgent = undefined;
1574
+ }
1575
+ this.#advisorYieldQueueUnsubscribe?.();
1576
+ this.#advisorYieldQueueUnsubscribe = undefined;
1577
+ }
1578
+
1579
+ async #promoteAdvisorContextModel(currentModel: Model): Promise<boolean> {
1580
+ const promotionSettings = this.settings.getGroup("contextPromotion");
1581
+ if (!promotionSettings.enabled) return false;
1582
+ const contextWindow = currentModel.contextWindow ?? 0;
1583
+ if (contextWindow <= 0) return false;
1584
+ const targetModel = await this.#resolveContextPromotionTarget(currentModel, contextWindow);
1585
+ if (!targetModel) return false;
1586
+
1587
+ const advisorSel = resolveRoleSelection(
1588
+ ["advisor"],
1589
+ this.settings,
1590
+ this.#modelRegistry.getAvailable(),
1591
+ this.#modelRegistry,
1592
+ );
1593
+ const advisorThinkingLevel = advisorSel?.thinkingLevel ?? ThinkingLevel.Medium;
1594
+
1595
+ try {
1596
+ this.#advisorAgent?.setModel(targetModel);
1597
+ this.#advisorAgent?.setThinkingLevel(toReasoningEffort(advisorThinkingLevel));
1598
+ this.#advisorAgent?.setDisableReasoning(shouldDisableReasoning(advisorThinkingLevel));
1599
+ this.#advisorAgent?.appendOnlyContext?.invalidateForModelChange();
1600
+ logger.debug("Advisor context promotion switched model on overflow", {
1601
+ from: `${currentModel.provider}/${currentModel.id}`,
1602
+ to: `${targetModel.provider}/${targetModel.id}`,
1603
+ });
1604
+ return true;
1605
+ } catch (error) {
1606
+ logger.warn("Advisor context promotion failed", {
1607
+ from: `${currentModel.provider}/${currentModel.id}`,
1608
+ to: `${targetModel.provider}/${targetModel.id}`,
1609
+ error: String(error),
1610
+ });
1611
+ return false;
1612
+ }
1613
+ }
1614
+
1615
+ async #maintainAdvisorContext(incomingTokens: number): Promise<boolean> {
1616
+ const advisor = this.#advisorAgent;
1617
+ if (!advisor) return false;
1618
+
1619
+ const compactionSettings = this.settings.getGroup("compaction");
1620
+ if (compactionSettings.strategy === "off") return false;
1621
+ if (!compactionSettings.enabled) return false;
1622
+
1623
+ const advisorModel = advisor.state.model;
1624
+ const contextWindow = advisorModel.contextWindow ?? 0;
1625
+ if (contextWindow <= 0) return false;
1626
+
1627
+ const messages = advisor.state.messages;
1628
+ let contextTokens = incomingTokens;
1629
+ for (const message of messages) {
1630
+ contextTokens += estimateTokens(message);
1631
+ }
1632
+
1633
+ if (!shouldCompact(contextTokens, contextWindow, compactionSettings)) {
1634
+ return false;
1635
+ }
1636
+
1637
+ // 1. Try promotion first
1638
+ if (await this.#promoteAdvisorContextModel(advisorModel)) {
1639
+ // Promotion succeeded, check if new model has enough space
1640
+ const newModel = advisor.state.model;
1641
+ const newWindow = newModel.contextWindow ?? 0;
1642
+ if (newWindow > 0) {
1643
+ const stillNeedsCompaction = shouldCompact(contextTokens, newWindow, compactionSettings);
1644
+ if (!stillNeedsCompaction) return false;
1645
+ }
1646
+ }
1647
+
1648
+ // 2. Run compaction on advisor messages
1649
+ const pathEntries: SessionEntry[] = messages.map((message, i) => {
1650
+ const id = `msg-${i}`;
1651
+ const parentId = i > 0 ? `msg-${i - 1}` : null;
1652
+ const timestamp = String(message.timestamp || Date.now());
1653
+
1654
+ if (message.role === "compactionSummary") {
1655
+ return {
1656
+ type: "compaction",
1657
+ id,
1658
+ parentId,
1659
+ timestamp,
1660
+ summary: message.summary,
1661
+ shortSummary: message.shortSummary,
1662
+ firstKeptEntryId: (message as any).firstKeptEntryId || `msg-${i + 1}`,
1663
+ tokensBefore: message.tokensBefore,
1664
+ } satisfies CompactionEntry;
1665
+ }
1666
+
1667
+ return {
1668
+ type: "message",
1669
+ id,
1670
+ parentId,
1671
+ timestamp,
1672
+ message,
1673
+ } satisfies SessionMessageEntry;
1674
+ });
1675
+
1676
+ const preparation = prepareCompaction(pathEntries, compactionSettings);
1677
+ if (!preparation) {
1678
+ // Cannot prepare compaction, fallback to re-prime
1679
+ return true;
1680
+ }
1681
+
1682
+ const advisorCompactionThinkingLevel: ThinkingLevel | undefined = advisor.state.disableReasoning
1683
+ ? ThinkingLevel.Off
1684
+ : advisor.state.thinkingLevel;
1685
+
1686
+ // Advisor state is in-memory-only, so snapcompact's frame archive has no
1687
+ // stable SessionEntry preserveData slot to carry across future advisor
1688
+ // maintenance runs. Use an LLM summary even when the primary session is
1689
+ // configured for snapcompact.
1690
+ const availableModels = this.#modelRegistry.getAvailable();
1691
+ const candidates = this.#resolveCompactionModelCandidates(advisorModel, availableModels);
1692
+ if (candidates.length === 0) {
1693
+ // No compaction candidates, fallback to re-prime
1694
+ return true;
1695
+ }
1696
+
1697
+ let compactResult: CompactionResult | undefined;
1698
+ let lastError: unknown;
1699
+
1700
+ for (const candidate of candidates) {
1701
+ const apiKey = await this.#modelRegistry.getApiKey(
1702
+ candidate,
1703
+ this.sessionId ? `${this.sessionId}-advisor` : undefined,
1704
+ );
1705
+ if (!apiKey) continue;
1706
+
1707
+ try {
1708
+ compactResult = await compact(
1709
+ preparation,
1710
+ candidate,
1711
+ this.#modelRegistry.resolver(candidate, this.sessionId ? `${this.sessionId}-advisor` : undefined),
1712
+ undefined,
1713
+ undefined,
1714
+ {
1715
+ thinkingLevel: advisorCompactionThinkingLevel,
1716
+ convertToLlm: messages => this.#convertToLlmForSideRequest(messages),
1717
+ },
1718
+ );
1719
+ break;
1720
+ } catch (error) {
1721
+ lastError = error;
1722
+ }
1723
+ }
1724
+
1725
+ if (!compactResult) {
1726
+ logger.warn("Advisor compaction failed, falling back to re-prime", { error: String(lastError) });
1727
+ return true;
1728
+ }
1729
+
1730
+ const summary = compactResult.summary;
1731
+ const shortSummary = compactResult.shortSummary;
1732
+ const firstKeptEntryId = compactResult.firstKeptEntryId;
1733
+ const tokensBefore = compactResult.tokensBefore;
1734
+
1735
+ // Rebuild messages with the compaction summary
1736
+ const summaryMessage = {
1737
+ ...createCompactionSummaryMessage(summary, tokensBefore, new Date().toISOString(), shortSummary),
1738
+ firstKeptEntryId,
1739
+ } as CompactionSummaryMessage & { firstKeptEntryId?: string };
1740
+
1741
+ advisor.replaceMessages([summaryMessage, ...preparation.recentMessages]);
1742
+ return false;
1743
+ }
1374
1744
 
1375
1745
  /** Model registry for API key resolution and model discovery */
1376
1746
  get modelRegistry(): ModelRegistry {
@@ -1619,8 +1989,34 @@ export class AgentSession {
1619
1989
  // Track last assistant message for auto-compaction check
1620
1990
  #lastAssistantMessage: AssistantMessage | undefined = undefined;
1621
1991
 
1622
- /** Internal handler for agent events - shared by subscribe and reconnect */
1992
+ /** Internal handler for agent events - shared by subscribe and reconnect.
1993
+ *
1994
+ * `agent_end` handling schedules deferred post-prompt recovery work
1995
+ * (compaction/handoff, context-promotion continuations). It is invoked
1996
+ * fire-and-forget by the agent's synchronous `#emit`, and only reaches
1997
+ * `#checkCompaction` after several internal awaits. `prompt()` runs
1998
+ * `#waitForPostPromptRecovery()` the instant `agent.prompt()` resolves — which
1999
+ * can land BEFORE the handler registers its tasks, so the wait would observe an
2000
+ * empty task set and return early, letting a deferred handoff/promotion race
2001
+ * prompt completion. Tracking the `agent_end` handler as a post-prompt task
2002
+ * that is registered SYNCHRONOUSLY (before the first await) closes that window:
2003
+ * `#postPromptTasksPromise` is set the moment `#emit` invokes this handler, so
2004
+ * the recovery wait always sees the in-flight handler and blocks until it — and
2005
+ * everything it schedules — settles. */
1623
2006
  #handleAgentEvent = async (event: AgentEvent): Promise<void> => {
2007
+ if (event.type !== "agent_end") {
2008
+ return this.#processAgentEvent(event);
2009
+ }
2010
+ const { promise, resolve } = Promise.withResolvers<void>();
2011
+ this.#trackPostPromptTask(promise);
2012
+ try {
2013
+ await this.#processAgentEvent(event);
2014
+ } finally {
2015
+ resolve();
2016
+ }
2017
+ };
2018
+
2019
+ #processAgentEvent = async (event: AgentEvent): Promise<void> => {
1624
2020
  // Plan-mode → compaction transition: stamp `SILENT_ABORT_MARKER` on the
1625
2021
  // persisted message BEFORE the obfuscator's display-side copy below.
1626
2022
  // Invariant (must hold across refactors): this branch precedes the
@@ -1787,6 +2183,14 @@ export class AgentSession {
1787
2183
  if (event.message.role === "assistant") {
1788
2184
  this.#lastAssistantMessage = event.message;
1789
2185
  const assistantMsg = event.message as AssistantMessage;
2186
+ if (assistantMsg.stopReason !== "aborted" && assistantMsg.stopReason !== "error" && assistantMsg.usage) {
2187
+ this.#lastProviderUsageNonMessage = {
2188
+ provider: assistantMsg.provider,
2189
+ model: assistantMsg.model,
2190
+ timestamp: assistantMsg.timestamp,
2191
+ tokens: this.#pendingProviderRequestNonMessageTokens ?? computeNonMessageTokens(this),
2192
+ };
2193
+ }
1790
2194
  const currentGrantsAnthropicPriority =
1791
2195
  this.serviceTier === "priority" || this.serviceTier === "claude-only";
1792
2196
  if (assistantMsg.disabledFeatures?.includes("priority") && currentGrantsAnthropicPriority) {
@@ -1933,6 +2337,9 @@ export class AgentSession {
1933
2337
  if (await this.#handleEmptyAssistantStop(msg)) {
1934
2338
  return;
1935
2339
  }
2340
+ if (await this.#handleUnexpectedAssistantStop(msg)) {
2341
+ return;
2342
+ }
1936
2343
 
1937
2344
  // A deliberate abort should settle the current turn, not trigger queued continuations.
1938
2345
  if (msg.stopReason === "aborted") {
@@ -3164,6 +3571,7 @@ export class AgentSession {
3164
3571
  this.#pendingIrcAsides = [];
3165
3572
  this.yieldQueue.clear();
3166
3573
  this.agent.setAsideMessageProvider(undefined);
3574
+ this.#stopAdvisorRuntime();
3167
3575
  this.#evalExecutionDisposing = true;
3168
3576
  }
3169
3577
 
@@ -4765,6 +5173,7 @@ export class AgentSession {
4765
5173
  this.#todoReminderCount = 0;
4766
5174
  this.#todoReminderAwaitingProgress = false;
4767
5175
  this.#emptyStopRetryCount = 0;
5176
+ this.#unexpectedStopRetryCount = 0;
4768
5177
 
4769
5178
  await this.#maybeRestoreRetryFallbackPrimary();
4770
5179
 
@@ -4905,7 +5314,12 @@ export class AgentSession {
4905
5314
  }
4906
5315
 
4907
5316
  const agentPromptOptions = options?.toolChoice ? { toolChoice: options.toolChoice } : undefined;
4908
- await this.#promptAgentWithIdleRetry(messages, agentPromptOptions);
5317
+ this.#pendingProviderRequestNonMessageTokens = computeNonMessageTokens(this);
5318
+ try {
5319
+ await this.#promptAgentWithIdleRetry(messages, agentPromptOptions);
5320
+ } finally {
5321
+ this.#pendingProviderRequestNonMessageTokens = undefined;
5322
+ }
4909
5323
  if (!options?.skipPostPromptRecoveryWait) {
4910
5324
  await this.#waitForPostPromptRecovery(generation);
4911
5325
  }
@@ -5562,6 +5976,7 @@ export class AgentSession {
5562
5976
  this.#todoReminderAwaitingProgress = false;
5563
5977
  this.#planReferenceSent = false;
5564
5978
  this.#planReferencePath = "local://PLAN.md";
5979
+ this.#advisorRuntime?.reset();
5565
5980
  this.#reconnectToAgent();
5566
5981
 
5567
5982
  // Emit session_switch event with reason "new" to hooks
@@ -6179,6 +6594,7 @@ export class AgentSession {
6179
6594
  await this.sessionManager.rewriteEntries();
6180
6595
  const sessionContext = this.buildDisplaySessionContext();
6181
6596
  this.agent.replaceMessages(sessionContext.messages);
6597
+ this.#advisorRuntime?.reset();
6182
6598
  this.#syncTodoPhasesFromBranch();
6183
6599
  this.#closeCodexProviderSessionsForHistoryRewrite();
6184
6600
  return result;
@@ -6208,9 +6624,9 @@ export class AgentSession {
6208
6624
  return undefined;
6209
6625
  }
6210
6626
 
6211
- await this.sessionManager.rewriteEntries();
6212
6627
  const sessionContext = this.buildDisplaySessionContext();
6213
6628
  this.agent.replaceMessages(sessionContext.messages);
6629
+ this.#advisorRuntime?.reset();
6214
6630
  this.#syncTodoPhasesFromBranch();
6215
6631
  this.#closeCodexProviderSessionsForHistoryRewrite();
6216
6632
  return result;
@@ -6261,6 +6677,7 @@ export class AgentSession {
6261
6677
  await this.sessionManager.rewriteEntries();
6262
6678
  const sessionContext = this.buildDisplaySessionContext();
6263
6679
  this.agent.replaceMessages(sessionContext.messages);
6680
+ this.#advisorRuntime?.reset();
6264
6681
  this.#closeCodexProviderSessionsForHistoryRewrite();
6265
6682
  return { removed };
6266
6683
  }
@@ -6311,6 +6728,7 @@ export class AgentSession {
6311
6728
  await this.sessionManager.rewriteEntries();
6312
6729
  const sessionContext = this.buildDisplaySessionContext();
6313
6730
  this.agent.replaceMessages(sessionContext.messages);
6731
+ this.#advisorRuntime?.reset();
6314
6732
  this.#closeCodexProviderSessionsForHistoryRewrite();
6315
6733
 
6316
6734
  return {
@@ -6527,6 +6945,7 @@ export class AgentSession {
6527
6945
  const newEntries = this.sessionManager.getEntries();
6528
6946
  const sessionContext = this.buildDisplaySessionContext();
6529
6947
  this.agent.replaceMessages(sessionContext.messages);
6948
+ this.#advisorRuntime?.reset();
6530
6949
  this.#syncTodoPhasesFromBranch();
6531
6950
  this.#closeCodexProviderSessionsForHistoryRewrite();
6532
6951
 
@@ -6746,6 +7165,7 @@ export class AgentSession {
6746
7165
  // Rebuild agent messages from session
6747
7166
  const sessionContext = this.buildDisplaySessionContext();
6748
7167
  this.agent.replaceMessages(sessionContext.messages);
7168
+ this.#advisorRuntime?.reset();
6749
7169
  this.#syncTodoPhasesFromBranch();
6750
7170
 
6751
7171
  return { document: handoffText, savedPath };
@@ -6771,13 +7191,37 @@ export class AgentSession {
6771
7191
  return tokens;
6772
7192
  }
6773
7193
 
7194
+ #estimatePrePromptContextTokens(messages: AgentMessage[], contextWindow: number): number {
7195
+ const currentUsage = this.getContextUsage({ contextWindow });
7196
+ if (typeof currentUsage?.tokens !== "number" || !Number.isFinite(currentUsage.tokens)) {
7197
+ return this.#estimatePendingPromptTokens(messages);
7198
+ }
7199
+
7200
+ const currentEstimate = this.#estimateContextTokens();
7201
+ if (!currentEstimate.providerAnchored) {
7202
+ return this.#estimatePendingPromptTokens(messages);
7203
+ }
7204
+
7205
+ let tokens = currentUsage.tokens;
7206
+ const previousNonMessageTokens = currentEstimate.providerNonMessageTokens;
7207
+ if (previousNonMessageTokens !== undefined) {
7208
+ const currentNonMessageTokens = computeNonMessageTokens(this);
7209
+ const nonMessageTokenGrowth = Math.max(0, currentNonMessageTokens - previousNonMessageTokens);
7210
+ tokens += nonMessageTokenGrowth;
7211
+ }
7212
+ for (const message of messages) {
7213
+ tokens += estimateTokens(message);
7214
+ }
7215
+ return tokens;
7216
+ }
7217
+
6774
7218
  async #runPrePromptCompactionIfNeeded(messages: AgentMessage[]): Promise<void> {
6775
7219
  const model = this.model;
6776
7220
  if (!model) return;
6777
7221
  const contextWindow = model.contextWindow ?? 0;
6778
7222
  if (contextWindow <= 0) return;
6779
7223
  const compactionSettings = this.settings.getGroup("compaction");
6780
- const contextTokens = this.#estimatePendingPromptTokens(messages);
7224
+ const contextTokens = this.#estimatePrePromptContextTokens(messages, contextWindow);
6781
7225
  if (!shouldCompact(contextTokens, contextWindow, compactionSettings)) return;
6782
7226
 
6783
7227
  // Auto-promote first: switching to a larger-context model avoids compacting
@@ -7027,6 +7471,71 @@ export class AgentSession {
7027
7471
  maxRetries: EMPTY_STOP_MAX_RETRIES,
7028
7472
  });
7029
7473
  }
7474
+ async #handleUnexpectedAssistantStop(assistantMessage: AssistantMessage): Promise<boolean> {
7475
+ if (!this.settings.get("features.unexpectedStopDetection")) {
7476
+ return false;
7477
+ }
7478
+ if (!isUnexpectedStopCandidate(assistantMessage)) {
7479
+ this.#unexpectedStopRetryCount = 0;
7480
+ return false;
7481
+ }
7482
+
7483
+ const text = assistantMessage.content
7484
+ .filter((content): content is TextContent => content.type === "text")
7485
+ .map(content => content.text)
7486
+ .join("\n");
7487
+ if (!/\S/.test(text)) {
7488
+ this.#unexpectedStopRetryCount = 0;
7489
+ return false;
7490
+ }
7491
+
7492
+ const controller = new AbortController();
7493
+ const timeout = setTimeout(() => controller.abort(), UNEXPECTED_STOP_TIMEOUT_MS);
7494
+ let classification: boolean | undefined;
7495
+ try {
7496
+ classification = await classifyUnexpectedStop(text, {
7497
+ settings: this.settings,
7498
+ registry: this.#modelRegistry,
7499
+ sessionId: this.sessionId,
7500
+ metadataResolver: (provider: string) => this.agent.metadataForProvider(provider),
7501
+ signal: controller.signal,
7502
+ });
7503
+ } finally {
7504
+ clearTimeout(timeout);
7505
+ }
7506
+
7507
+ if (classification !== true) {
7508
+ this.#unexpectedStopRetryCount = 0;
7509
+ return false;
7510
+ }
7511
+
7512
+ this.#unexpectedStopRetryCount++;
7513
+ if (this.#unexpectedStopRetryCount > UNEXPECTED_STOP_MAX_RETRIES) {
7514
+ logger.warn("Assistant returned unexpected stop after retry cap", {
7515
+ attempts: this.#unexpectedStopRetryCount - 1,
7516
+ model: assistantMessage.model,
7517
+ provider: assistantMessage.provider,
7518
+ });
7519
+ this.#unexpectedStopRetryCount = 0;
7520
+ return false;
7521
+ }
7522
+
7523
+ this.agent.appendMessage({
7524
+ role: "developer",
7525
+ content: [{ type: "text", text: this.#unexpectedStopRetryReminder() }],
7526
+ attribution: "agent",
7527
+ timestamp: Date.now(),
7528
+ });
7529
+ this.#scheduleAgentContinue({ generation: this.#promptGeneration });
7530
+ return true;
7531
+ }
7532
+
7533
+ #unexpectedStopRetryReminder(): string {
7534
+ return prompt.render(unexpectedStopRetryTemplate, {
7535
+ retryCount: this.#unexpectedStopRetryCount,
7536
+ maxRetries: UNEXPECTED_STOP_MAX_RETRIES,
7537
+ });
7538
+ }
7030
7539
 
7031
7540
  #removeEmptyStopFromActiveContext(assistantMessage: AssistantMessage): void {
7032
7541
  const messages = this.agent.state.messages;
@@ -7094,6 +7603,7 @@ export class AgentSession {
7094
7603
  }
7095
7604
  const safeCount = Math.max(0, Math.min(checkpointState.checkpointMessageCount, this.agent.state.messages.length));
7096
7605
  this.agent.replaceMessages(this.agent.state.messages.slice(0, safeCount));
7606
+ this.#advisorRuntime?.reset();
7097
7607
  try {
7098
7608
  this.sessionManager.branchWithSummary(checkpointState.checkpointEntryId, report, {
7099
7609
  startedAt: checkpointState.startedAt,
@@ -7702,6 +8212,10 @@ export class AgentSession {
7702
8212
  }
7703
8213
 
7704
8214
  #getCompactionModelCandidates(availableModels: Model[]): Model[] {
8215
+ return this.#resolveCompactionModelCandidates(this.model, availableModels);
8216
+ }
8217
+
8218
+ #resolveCompactionModelCandidates(preferredModel: Model | null | undefined, availableModels: Model[]): Model[] {
7705
8219
  const candidates: Model[] = [];
7706
8220
  const seen = new Set<string>();
7707
8221
 
@@ -7713,15 +8227,9 @@ export class AgentSession {
7713
8227
  candidates.push(model);
7714
8228
  };
7715
8229
 
7716
- const currentModel = this.model;
7717
- // Prefer the active session's model: it's what the user is actively using,
7718
- // and routing compaction to a different provider (e.g. an OpenAI default
7719
- // model while the chat is on Anthropic) changes provider-specific behavior
7720
- // like remote compaction endpoints. Role-based candidates only kick in
7721
- // as auth fallbacks when the current model has no usable credentials.
7722
- addCandidate(currentModel);
8230
+ addCandidate(preferredModel ?? undefined);
7723
8231
  for (const role of MODEL_ROLE_IDS) {
7724
- addCandidate(this.#resolveRoleModelFull(role, availableModels, currentModel).model);
8232
+ addCandidate(this.#resolveRoleModelFull(role, availableModels, preferredModel ?? undefined).model);
7725
8233
  }
7726
8234
 
7727
8235
  const sortedByContext = [...availableModels].sort((a, b) => (b.contextWindow ?? 0) - (a.contextWindow ?? 0));
@@ -8279,6 +8787,7 @@ export class AgentSession {
8279
8787
  const newEntries = this.sessionManager.getEntries();
8280
8788
  const sessionContext = this.buildDisplaySessionContext();
8281
8789
  this.agent.replaceMessages(sessionContext.messages);
8790
+ this.#advisorRuntime?.reset();
8282
8791
  this.#syncTodoPhasesFromBranch();
8283
8792
  this.#closeCodexProviderSessionsForHistoryRewrite();
8284
8793
 
@@ -9924,6 +10433,7 @@ export class AgentSession {
9924
10433
  this.#applyThinkingLevelToAgent(previousThinkingLevel);
9925
10434
  this.agent.serviceTier = previousServiceTier;
9926
10435
  this.#syncTodoPhasesFromBranch();
10436
+ this.#advisorRuntime?.reset();
9927
10437
  this.#reconnectToAgent();
9928
10438
  if (restoreMcpError) {
9929
10439
  throw restoreMcpError;
@@ -10005,6 +10515,7 @@ export class AgentSession {
10005
10515
 
10006
10516
  if (!skipConversationRestore) {
10007
10517
  this.agent.replaceMessages(sessionContext.messages);
10518
+ this.#advisorRuntime?.reset();
10008
10519
  this.#closeCodexProviderSessionsForHistoryRewrite();
10009
10520
  }
10010
10521
 
@@ -10171,6 +10682,7 @@ export class AgentSession {
10171
10682
  const displayContext = deobfuscateSessionContext(stateContext, this.#obfuscator);
10172
10683
  await this.#restoreMCPSelectionsForSessionContext(displayContext);
10173
10684
  this.agent.replaceMessages(displayContext.messages);
10685
+ this.#advisorRuntime?.reset();
10174
10686
  this.#syncTodoPhasesFromBranch();
10175
10687
  this.#closeCodexProviderSessionsForHistoryRewrite();
10176
10688
 
@@ -10528,6 +11040,8 @@ export class AgentSession {
10528
11040
  */
10529
11041
  #estimateContextTokens(): {
10530
11042
  tokens: number;
11043
+ providerAnchored: boolean;
11044
+ providerNonMessageTokens?: number;
10531
11045
  } {
10532
11046
  const messages = this.messages;
10533
11047
 
@@ -10554,10 +11068,19 @@ export class AgentSession {
10554
11068
  }
10555
11069
  return {
10556
11070
  tokens: estimated,
11071
+ providerAnchored: false,
10557
11072
  };
10558
11073
  }
10559
11074
 
10560
11075
  const usageTokens = calculatePromptTokens(lastUsage);
11076
+ const providerNonMessage =
11077
+ this.#lastProviderUsageNonMessage &&
11078
+ messages[lastUsageIndex]?.role === "assistant" &&
11079
+ this.#lastProviderUsageNonMessage.provider === (messages[lastUsageIndex] as AssistantMessage).provider &&
11080
+ this.#lastProviderUsageNonMessage.model === (messages[lastUsageIndex] as AssistantMessage).model &&
11081
+ this.#lastProviderUsageNonMessage.timestamp === (messages[lastUsageIndex] as AssistantMessage).timestamp
11082
+ ? this.#lastProviderUsageNonMessage.tokens
11083
+ : undefined;
10561
11084
  let trailingTokens = 0;
10562
11085
  for (let i = lastUsageIndex + 1; i < messages.length; i++) {
10563
11086
  trailingTokens += estimateTokens(messages[i]);
@@ -10565,6 +11088,8 @@ export class AgentSession {
10565
11088
 
10566
11089
  return {
10567
11090
  tokens: usageTokens + trailingTokens,
11091
+ providerAnchored: true,
11092
+ providerNonMessageTokens: providerNonMessage,
10568
11093
  };
10569
11094
  }
10570
11095
 
@@ -10654,7 +11179,10 @@ export class AgentSession {
10654
11179
  * Format the entire session as plain text for clipboard export.
10655
11180
  * Includes user messages, assistant text, thinking blocks, tool calls, and tool results.
10656
11181
  */
10657
- formatSessionAsText(): string {
11182
+ formatSessionAsText(options?: { compact?: boolean }): string {
11183
+ if (options?.compact) {
11184
+ return formatSessionHistoryMarkdown(this.messages);
11185
+ }
10658
11186
  return formatSessionDumpText({
10659
11187
  messages: this.messages,
10660
11188
  systemPrompt: this.agent.state.systemPrompt,
@@ -10664,6 +11192,179 @@ export class AgentSession {
10664
11192
  });
10665
11193
  }
10666
11194
 
11195
+ /**
11196
+ * Enable or disable the advisor for this session. The setting is persisted,
11197
+ * and the runtime is started or stopped to match.
11198
+ *
11199
+ * @returns true when the advisor is actively running after the call.
11200
+ */
11201
+ setAdvisorEnabled(enabled: boolean): boolean {
11202
+ if (enabled) {
11203
+ this.settings.clearOverride("advisor.enabled");
11204
+ this.settings.set("advisor.enabled", true);
11205
+ return this.#buildAdvisorRuntime(true);
11206
+ }
11207
+ this.settings.set("advisor.enabled", false);
11208
+ this.#stopAdvisorRuntime();
11209
+ return false;
11210
+ }
11211
+
11212
+ /**
11213
+ * Toggle the advisor setting and start/stop the runtime accordingly.
11214
+ *
11215
+ * @returns true when the advisor is actively running after the call.
11216
+ */
11217
+ toggleAdvisorEnabled(): boolean {
11218
+ return this.setAdvisorEnabled(!this.settings.get("advisor.enabled"));
11219
+ }
11220
+
11221
+ /**
11222
+ * Whether a live advisor agent is attached to this session. True only when
11223
+ * `advisor.enabled` is set AND a model resolved for the `advisor` role AND
11224
+ * the advisor applies to this agent kind — i.e. the actual runtime exists,
11225
+ * not merely the setting. Drives the status-line badge and `/dump advisor`.
11226
+ */
11227
+ isAdvisorActive(): boolean {
11228
+ return this.#advisorAgent !== undefined;
11229
+ }
11230
+
11231
+ /**
11232
+ * Return structured advisor stats for the status command and TUI panel.
11233
+ */
11234
+ getAdvisorStats(): AdvisorStats {
11235
+ const configured = this.settings.get("advisor.enabled") as boolean;
11236
+ const advisor = this.#advisorAgent;
11237
+ if (!advisor) {
11238
+ return {
11239
+ configured,
11240
+ active: false,
11241
+ contextWindow: 0,
11242
+ contextTokens: 0,
11243
+ tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
11244
+ cost: 0,
11245
+ messages: { user: 0, assistant: 0, total: 0 },
11246
+ };
11247
+ }
11248
+ const model = advisor.state.model;
11249
+ const messages = advisor.state.messages;
11250
+ const contextTokens = this.#estimateAdvisorContextTokens(messages);
11251
+ let input = 0;
11252
+ let output = 0;
11253
+ let cacheRead = 0;
11254
+ let cacheWrite = 0;
11255
+ let cost = 0;
11256
+ let user = 0;
11257
+ let assistant = 0;
11258
+ for (const message of messages) {
11259
+ if (message.role === "user") user++;
11260
+ if (message.role === "assistant") {
11261
+ assistant++;
11262
+ const assistantMsg = message as AssistantMessage;
11263
+ input += assistantMsg.usage.input;
11264
+ output += assistantMsg.usage.output;
11265
+ cacheRead += assistantMsg.usage.cacheRead;
11266
+ cacheWrite += assistantMsg.usage.cacheWrite;
11267
+ cost += assistantMsg.usage.cost.total;
11268
+ }
11269
+ }
11270
+ return {
11271
+ configured,
11272
+ active: true,
11273
+ model,
11274
+ contextWindow: model.contextWindow ?? 0,
11275
+ contextTokens,
11276
+ tokens: {
11277
+ input,
11278
+ output,
11279
+ cacheRead,
11280
+ cacheWrite,
11281
+ total: input + output + cacheRead + cacheWrite,
11282
+ },
11283
+ cost,
11284
+ messages: { user, assistant, total: messages.length },
11285
+ };
11286
+ }
11287
+
11288
+ /**
11289
+ * Format a concise advisor status line for ACP/text output.
11290
+ */
11291
+ formatAdvisorStatus(): string {
11292
+ const stats = this.getAdvisorStats();
11293
+ if (!stats.active) {
11294
+ return stats.configured
11295
+ ? "Advisor setting is enabled, but no model is assigned to the 'advisor' role."
11296
+ : "Advisor is disabled.";
11297
+ }
11298
+ const model = stats.model!;
11299
+ const contextLine =
11300
+ stats.contextWindow > 0
11301
+ ? `Context: ${stats.contextTokens.toLocaleString()} / ${stats.contextWindow.toLocaleString()} tokens (${Math.round((stats.contextTokens / stats.contextWindow) * 100)}%)`
11302
+ : `Context: ${stats.contextTokens.toLocaleString()} tokens`;
11303
+ const spendParts = [
11304
+ `${stats.tokens.input.toLocaleString()} input`,
11305
+ `${stats.tokens.output.toLocaleString()} output`,
11306
+ ];
11307
+ if (stats.tokens.cacheRead > 0) spendParts.push(`${stats.tokens.cacheRead.toLocaleString()} cache read`);
11308
+ if (stats.tokens.cacheWrite > 0) spendParts.push(`${stats.tokens.cacheWrite.toLocaleString()} cache write`);
11309
+ const spendLine = `Spend: ${spendParts.join(", ")}, $${stats.cost.toFixed(4)}`;
11310
+ return `Advisor is enabled (${model.provider}/${model.id}). ${contextLine}. ${spendLine}.`;
11311
+ }
11312
+
11313
+ /**
11314
+ * Estimate the advisor's current context tokens. When the advisor has a
11315
+ * recent non-aborted assistant message with usage, use that prompt's token
11316
+ * count and add a trailing estimate for messages after it. Otherwise estimate
11317
+ * every message.
11318
+ */
11319
+ #estimateAdvisorContextTokens(messages: AgentMessage[]): number {
11320
+ let lastUsageIndex: number | null = null;
11321
+ let lastUsage: AssistantMessage["usage"] | undefined;
11322
+ for (let i = messages.length - 1; i >= 0; i--) {
11323
+ const msg = messages[i];
11324
+ if (msg.role === "assistant") {
11325
+ const assistantMsg = msg as AssistantMessage;
11326
+ if (assistantMsg.stopReason !== "aborted" && assistantMsg.stopReason !== "error" && assistantMsg.usage) {
11327
+ lastUsage = assistantMsg.usage;
11328
+ lastUsageIndex = i;
11329
+ break;
11330
+ }
11331
+ }
11332
+ }
11333
+ if (!lastUsage || lastUsageIndex === null) {
11334
+ let estimated = 0;
11335
+ for (const message of messages) {
11336
+ estimated += estimateTokens(message);
11337
+ }
11338
+ return estimated;
11339
+ }
11340
+ let trailingTokens = 0;
11341
+ for (let i = lastUsageIndex + 1; i < messages.length; i++) {
11342
+ trailingTokens += estimateTokens(messages[i]);
11343
+ }
11344
+ return calculatePromptTokens(lastUsage) + trailingTokens;
11345
+ }
11346
+
11347
+ /**
11348
+ * Format the advisor agent's own transcript (its system prompt, config,
11349
+ * tools, and the markdown deltas it received plus its thinking/advise/read
11350
+ * calls) as plain text — the advisor-side equivalent of
11351
+ * {@link formatSessionAsText}. Returns null when no advisor is active.
11352
+ */
11353
+ formatAdvisorHistoryAsText(options?: { compact?: boolean }): string | null {
11354
+ const advisor = this.#advisorAgent;
11355
+ if (!advisor) return null;
11356
+ if (options?.compact) {
11357
+ return formatSessionHistoryMarkdown(advisor.state.messages);
11358
+ }
11359
+ return formatSessionDumpText({
11360
+ messages: advisor.state.messages,
11361
+ systemPrompt: advisor.state.systemPrompt,
11362
+ model: advisor.state.model,
11363
+ thinkingLevel: advisor.state.thinkingLevel,
11364
+ tools: advisor.state.tools,
11365
+ });
11366
+ }
11367
+
10667
11368
  // =========================================================================
10668
11369
  // Extension System
10669
11370
  // =========================================================================