@oh-my-pi/pi-coding-agent 15.13.3 → 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 (50) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/dist/cli.js +506 -443
  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 +44 -5
  10. package/dist/types/modes/components/advisor-message.d.ts +9 -0
  11. package/dist/types/modes/components/assistant-message.d.ts +1 -0
  12. package/dist/types/modes/controllers/command-controller.d.ts +3 -1
  13. package/dist/types/modes/interactive-mode.d.ts +3 -1
  14. package/dist/types/modes/types.d.ts +3 -1
  15. package/dist/types/sdk.d.ts +3 -3
  16. package/dist/types/session/agent-session.d.ts +71 -2
  17. package/dist/types/session/session-history-format.d.ts +4 -0
  18. package/dist/types/session/yield-queue.d.ts +2 -0
  19. package/dist/types/tools/path-utils.d.ts +1 -0
  20. package/dist/types/tools/report-tool-issue.d.ts +0 -1
  21. package/package.json +13 -13
  22. package/src/advisor/__tests__/advisor.test.ts +586 -0
  23. package/src/advisor/advise-tool.ts +87 -0
  24. package/src/advisor/index.ts +3 -0
  25. package/src/advisor/runtime.ts +248 -0
  26. package/src/advisor/watchdog.ts +83 -0
  27. package/src/config/model-roles.ts +13 -1
  28. package/src/config/settings-schema.ts +42 -5
  29. package/src/internal-urls/docs-index.generated.ts +6 -5
  30. package/src/main.ts +4 -0
  31. package/src/modes/components/advisor-message.ts +99 -0
  32. package/src/modes/components/agent-hub.ts +7 -0
  33. package/src/modes/components/assistant-message.ts +86 -0
  34. package/src/modes/components/status-line/segments.ts +20 -7
  35. package/src/modes/controllers/command-controller.ts +69 -2
  36. package/src/modes/interactive-mode.ts +12 -2
  37. package/src/modes/types.ts +3 -1
  38. package/src/modes/utils/ui-helpers.ts +9 -0
  39. package/src/prompts/advisor/advise-tool.md +1 -0
  40. package/src/prompts/advisor/system.md +31 -0
  41. package/src/sdk.ts +52 -13
  42. package/src/session/agent-session.ts +560 -13
  43. package/src/session/session-dump-format.ts +15 -131
  44. package/src/session/session-history-format.ts +30 -11
  45. package/src/session/yield-queue.ts +5 -1
  46. package/src/slash-commands/builtin-registry.ts +102 -4
  47. package/src/system-prompt.ts +1 -1
  48. package/src/tools/path-utils.ts +33 -2
  49. package/src/tools/report-tool-issue.ts +2 -7
  50. 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" };
@@ -268,6 +283,7 @@ import { getLatestCompactionEntry, getRestorableSessionModels } from "./session-
268
283
  import { formatSessionDumpText } from "./session-dump-format";
269
284
  import type { BranchSummaryEntry, CompactionEntry, NewSessionOptions } from "./session-entries";
270
285
  import { EPHEMERAL_MODEL_CHANGE_ROLE } from "./session-entries";
286
+ import { formatSessionHistoryMarkdown } from "./session-history-format";
271
287
  import type { SessionManager } from "./session-manager";
272
288
  import type { ShakeMode, ShakeResult } from "./shake-types";
273
289
  import { ToolChoiceQueue } from "./tool-choice-queue";
@@ -457,6 +473,15 @@ export interface AgentSessionConfig {
457
473
  * so that credential sticky selection is consistent with the session's streaming calls.
458
474
  */
459
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;
460
485
  }
461
486
 
462
487
  /** Options for AgentSession.prompt() */
@@ -539,6 +564,28 @@ export interface SessionStats {
539
564
  cost: number;
540
565
  }
541
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
+
542
589
  export interface FreshSessionResult {
543
590
  previousSessionId: string;
544
591
  sessionId: string;
@@ -944,6 +991,12 @@ export class AgentSession {
944
991
  #planModeState: PlanModeState | undefined;
945
992
  #goalModeState: GoalModeState | undefined;
946
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;
947
1000
  #goalTurnCounter = 0;
948
1001
  #planReferenceSent = false;
949
1002
  #planReferencePath = "local://PLAN.md";
@@ -1234,6 +1287,8 @@ export class AgentSession {
1234
1287
  this.#customCommands = config.customCommands ?? [];
1235
1288
  this.#skillsSettings = config.skillsSettings;
1236
1289
  this.#modelRegistry = config.modelRegistry;
1290
+ this.#advisorReadOnlyTools = config.advisorReadOnlyTools;
1291
+ this.#advisorWatchdogPrompt = config.advisorWatchdogPrompt;
1237
1292
  this.#validateRetryFallbackChains();
1238
1293
  this.#toolRegistry = config.toolRegistry ?? new Map();
1239
1294
  this.#requestedToolNames = config.requestedToolNames;
@@ -1266,6 +1321,17 @@ export class AgentSession {
1266
1321
  };
1267
1322
  this.agent.setProviderResponseInterceptor(this.#onResponse);
1268
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
+ });
1269
1335
  this.yieldQueue = new YieldQueue({
1270
1336
  isStreaming: () => this.isStreaming,
1271
1337
  injectIdle: async messages => {
@@ -1377,12 +1443,304 @@ export class AgentSession {
1377
1443
  },
1378
1444
  });
1379
1445
 
1446
+ if (this.settings.get("advisor.enabled")) this.#buildAdvisorRuntime();
1447
+
1380
1448
  // Always subscribe to agent events for internal handling
1381
1449
  // (session persistence, hooks, auto-compaction, retry logic)
1382
1450
  this.#unsubscribeAgent = this.agent.subscribe(this.#handleAgentEvent);
1383
1451
  // Re-evaluate append-only context mode when the setting changes at runtime.
1384
1452
  this.#unsubscribeAppendOnly = onAppendOnlyModeChanged(_value => this.#syncAppendOnlyContext(this.model));
1385
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
+ }
1386
1744
 
1387
1745
  /** Model registry for API key resolution and model discovery */
1388
1746
  get modelRegistry(): ModelRegistry {
@@ -3213,6 +3571,7 @@ export class AgentSession {
3213
3571
  this.#pendingIrcAsides = [];
3214
3572
  this.yieldQueue.clear();
3215
3573
  this.agent.setAsideMessageProvider(undefined);
3574
+ this.#stopAdvisorRuntime();
3216
3575
  this.#evalExecutionDisposing = true;
3217
3576
  }
3218
3577
 
@@ -5617,6 +5976,7 @@ export class AgentSession {
5617
5976
  this.#todoReminderAwaitingProgress = false;
5618
5977
  this.#planReferenceSent = false;
5619
5978
  this.#planReferencePath = "local://PLAN.md";
5979
+ this.#advisorRuntime?.reset();
5620
5980
  this.#reconnectToAgent();
5621
5981
 
5622
5982
  // Emit session_switch event with reason "new" to hooks
@@ -6234,6 +6594,7 @@ export class AgentSession {
6234
6594
  await this.sessionManager.rewriteEntries();
6235
6595
  const sessionContext = this.buildDisplaySessionContext();
6236
6596
  this.agent.replaceMessages(sessionContext.messages);
6597
+ this.#advisorRuntime?.reset();
6237
6598
  this.#syncTodoPhasesFromBranch();
6238
6599
  this.#closeCodexProviderSessionsForHistoryRewrite();
6239
6600
  return result;
@@ -6263,9 +6624,9 @@ export class AgentSession {
6263
6624
  return undefined;
6264
6625
  }
6265
6626
 
6266
- await this.sessionManager.rewriteEntries();
6267
6627
  const sessionContext = this.buildDisplaySessionContext();
6268
6628
  this.agent.replaceMessages(sessionContext.messages);
6629
+ this.#advisorRuntime?.reset();
6269
6630
  this.#syncTodoPhasesFromBranch();
6270
6631
  this.#closeCodexProviderSessionsForHistoryRewrite();
6271
6632
  return result;
@@ -6316,6 +6677,7 @@ export class AgentSession {
6316
6677
  await this.sessionManager.rewriteEntries();
6317
6678
  const sessionContext = this.buildDisplaySessionContext();
6318
6679
  this.agent.replaceMessages(sessionContext.messages);
6680
+ this.#advisorRuntime?.reset();
6319
6681
  this.#closeCodexProviderSessionsForHistoryRewrite();
6320
6682
  return { removed };
6321
6683
  }
@@ -6366,6 +6728,7 @@ export class AgentSession {
6366
6728
  await this.sessionManager.rewriteEntries();
6367
6729
  const sessionContext = this.buildDisplaySessionContext();
6368
6730
  this.agent.replaceMessages(sessionContext.messages);
6731
+ this.#advisorRuntime?.reset();
6369
6732
  this.#closeCodexProviderSessionsForHistoryRewrite();
6370
6733
 
6371
6734
  return {
@@ -6582,6 +6945,7 @@ export class AgentSession {
6582
6945
  const newEntries = this.sessionManager.getEntries();
6583
6946
  const sessionContext = this.buildDisplaySessionContext();
6584
6947
  this.agent.replaceMessages(sessionContext.messages);
6948
+ this.#advisorRuntime?.reset();
6585
6949
  this.#syncTodoPhasesFromBranch();
6586
6950
  this.#closeCodexProviderSessionsForHistoryRewrite();
6587
6951
 
@@ -6801,6 +7165,7 @@ export class AgentSession {
6801
7165
  // Rebuild agent messages from session
6802
7166
  const sessionContext = this.buildDisplaySessionContext();
6803
7167
  this.agent.replaceMessages(sessionContext.messages);
7168
+ this.#advisorRuntime?.reset();
6804
7169
  this.#syncTodoPhasesFromBranch();
6805
7170
 
6806
7171
  return { document: handoffText, savedPath };
@@ -6838,8 +7203,11 @@ export class AgentSession {
6838
7203
  }
6839
7204
 
6840
7205
  let tokens = currentUsage.tokens;
6841
- if (currentEstimate.providerNonMessageTokens !== undefined) {
6842
- tokens += Math.max(0, computeNonMessageTokens(this) - currentEstimate.providerNonMessageTokens);
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;
6843
7211
  }
6844
7212
  for (const message of messages) {
6845
7213
  tokens += estimateTokens(message);
@@ -7235,6 +7603,7 @@ export class AgentSession {
7235
7603
  }
7236
7604
  const safeCount = Math.max(0, Math.min(checkpointState.checkpointMessageCount, this.agent.state.messages.length));
7237
7605
  this.agent.replaceMessages(this.agent.state.messages.slice(0, safeCount));
7606
+ this.#advisorRuntime?.reset();
7238
7607
  try {
7239
7608
  this.sessionManager.branchWithSummary(checkpointState.checkpointEntryId, report, {
7240
7609
  startedAt: checkpointState.startedAt,
@@ -7843,6 +8212,10 @@ export class AgentSession {
7843
8212
  }
7844
8213
 
7845
8214
  #getCompactionModelCandidates(availableModels: Model[]): Model[] {
8215
+ return this.#resolveCompactionModelCandidates(this.model, availableModels);
8216
+ }
8217
+
8218
+ #resolveCompactionModelCandidates(preferredModel: Model | null | undefined, availableModels: Model[]): Model[] {
7846
8219
  const candidates: Model[] = [];
7847
8220
  const seen = new Set<string>();
7848
8221
 
@@ -7854,15 +8227,9 @@ export class AgentSession {
7854
8227
  candidates.push(model);
7855
8228
  };
7856
8229
 
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);
8230
+ addCandidate(preferredModel ?? undefined);
7864
8231
  for (const role of MODEL_ROLE_IDS) {
7865
- addCandidate(this.#resolveRoleModelFull(role, availableModels, currentModel).model);
8232
+ addCandidate(this.#resolveRoleModelFull(role, availableModels, preferredModel ?? undefined).model);
7866
8233
  }
7867
8234
 
7868
8235
  const sortedByContext = [...availableModels].sort((a, b) => (b.contextWindow ?? 0) - (a.contextWindow ?? 0));
@@ -8420,6 +8787,7 @@ export class AgentSession {
8420
8787
  const newEntries = this.sessionManager.getEntries();
8421
8788
  const sessionContext = this.buildDisplaySessionContext();
8422
8789
  this.agent.replaceMessages(sessionContext.messages);
8790
+ this.#advisorRuntime?.reset();
8423
8791
  this.#syncTodoPhasesFromBranch();
8424
8792
  this.#closeCodexProviderSessionsForHistoryRewrite();
8425
8793
 
@@ -10065,6 +10433,7 @@ export class AgentSession {
10065
10433
  this.#applyThinkingLevelToAgent(previousThinkingLevel);
10066
10434
  this.agent.serviceTier = previousServiceTier;
10067
10435
  this.#syncTodoPhasesFromBranch();
10436
+ this.#advisorRuntime?.reset();
10068
10437
  this.#reconnectToAgent();
10069
10438
  if (restoreMcpError) {
10070
10439
  throw restoreMcpError;
@@ -10146,6 +10515,7 @@ export class AgentSession {
10146
10515
 
10147
10516
  if (!skipConversationRestore) {
10148
10517
  this.agent.replaceMessages(sessionContext.messages);
10518
+ this.#advisorRuntime?.reset();
10149
10519
  this.#closeCodexProviderSessionsForHistoryRewrite();
10150
10520
  }
10151
10521
 
@@ -10312,6 +10682,7 @@ export class AgentSession {
10312
10682
  const displayContext = deobfuscateSessionContext(stateContext, this.#obfuscator);
10313
10683
  await this.#restoreMCPSelectionsForSessionContext(displayContext);
10314
10684
  this.agent.replaceMessages(displayContext.messages);
10685
+ this.#advisorRuntime?.reset();
10315
10686
  this.#syncTodoPhasesFromBranch();
10316
10687
  this.#closeCodexProviderSessionsForHistoryRewrite();
10317
10688
 
@@ -10808,7 +11179,10 @@ export class AgentSession {
10808
11179
  * Format the entire session as plain text for clipboard export.
10809
11180
  * Includes user messages, assistant text, thinking blocks, tool calls, and tool results.
10810
11181
  */
10811
- formatSessionAsText(): string {
11182
+ formatSessionAsText(options?: { compact?: boolean }): string {
11183
+ if (options?.compact) {
11184
+ return formatSessionHistoryMarkdown(this.messages);
11185
+ }
10812
11186
  return formatSessionDumpText({
10813
11187
  messages: this.messages,
10814
11188
  systemPrompt: this.agent.state.systemPrompt,
@@ -10818,6 +11192,179 @@ export class AgentSession {
10818
11192
  });
10819
11193
  }
10820
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
+
10821
11368
  // =========================================================================
10822
11369
  // Extension System
10823
11370
  // =========================================================================