@oh-my-pi/pi-coding-agent 17.0.8 → 17.1.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 (340) hide show
  1. package/CHANGELOG.md +87 -1
  2. package/README.md +1 -1
  3. package/dist/cli.js +5495 -5021
  4. package/dist/types/async/job-manager.d.ts +37 -1
  5. package/dist/types/cli/args.d.ts +2 -0
  6. package/dist/types/cli/bench-cli.d.ts +41 -0
  7. package/dist/types/commands/bench.d.ts +15 -3
  8. package/dist/types/commands/launch.d.ts +4 -0
  9. package/dist/types/config/__tests__/model-registry.test.d.ts +1 -0
  10. package/dist/types/config/model-registry.d.ts +30 -1
  11. package/dist/types/config/models-config-schema.d.ts +18 -6
  12. package/dist/types/config/models-config.d.ts +14 -5
  13. package/dist/types/config/provider-globals.d.ts +2 -2
  14. package/dist/types/config/service-tier.d.ts +5 -1
  15. package/dist/types/config/settings-schema.d.ts +350 -50
  16. package/dist/types/config/settings.d.ts +7 -0
  17. package/dist/types/dap/client.d.ts +20 -0
  18. package/dist/types/exec/bash-executor.d.ts +38 -0
  19. package/dist/types/exec/direnv.d.ts +33 -0
  20. package/dist/types/export/html/args.d.ts +15 -0
  21. package/dist/types/export/html/index.d.ts +11 -26
  22. package/dist/types/export/html/web-palette.d.ts +55 -116
  23. package/dist/types/extensibility/extensions/loader.d.ts +3 -0
  24. package/dist/types/extensibility/extensions/types.d.ts +17 -2
  25. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +1 -0
  26. package/dist/types/extensibility/legacy-pi-tui-shim.d.ts +10 -0
  27. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +14 -1
  28. package/dist/types/internal-urls/registry-helpers.d.ts +11 -0
  29. package/dist/types/live/controller.d.ts +40 -0
  30. package/dist/types/live/protocol.d.ts +85 -0
  31. package/dist/types/live/protocol.test.d.ts +1 -0
  32. package/dist/types/live/transport.d.ts +48 -0
  33. package/dist/types/live/visualizer.d.ts +33 -0
  34. package/dist/types/lsp/client.d.ts +18 -1
  35. package/dist/types/mcp/config-writer.test.d.ts +1 -0
  36. package/dist/types/mcp/manager.d.ts +6 -2
  37. package/dist/types/mcp/oauth-discovery.d.ts +2 -0
  38. package/dist/types/mcp/render.d.ts +2 -2
  39. package/dist/types/mcp/smithery-auth.d.ts +1 -1
  40. package/dist/types/mcp/smithery-auth.test.d.ts +1 -0
  41. package/dist/types/mcp/tool-bridge.d.ts +7 -3
  42. package/dist/types/mcp/types.d.ts +6 -0
  43. package/dist/types/memory-backend/tool-names.d.ts +2 -0
  44. package/dist/types/modes/components/login-dialog.d.ts +2 -2
  45. package/dist/types/modes/components/oauth-selector.d.ts +8 -0
  46. package/dist/types/modes/components/plan-review-overlay.d.ts +26 -0
  47. package/dist/types/modes/components/settings-defs.d.ts +8 -1
  48. package/dist/types/modes/components/status-line/component.jj-cache.test.d.ts +1 -0
  49. package/dist/types/modes/components/tool-execution.d.ts +9 -1
  50. package/dist/types/modes/controllers/command-controller.d.ts +1 -1
  51. package/dist/types/modes/controllers/event-controller.d.ts +6 -1
  52. package/dist/types/modes/controllers/live-command-controller.d.ts +14 -0
  53. package/dist/types/modes/controllers/mcp-command-controller.d.ts +3 -0
  54. package/dist/types/modes/interactive-mode.d.ts +5 -0
  55. package/dist/types/modes/rpc/rpc-client.d.ts +10 -1
  56. package/dist/types/modes/rpc/rpc-frame.d.ts +16 -0
  57. package/dist/types/modes/rpc/rpc-messages.d.ts +26 -0
  58. package/dist/types/modes/rpc/rpc-types.d.ts +40 -0
  59. package/dist/types/modes/setup-wizard/scenes/sign-in.d.ts +1 -1
  60. package/dist/types/modes/setup-wizard/scenes/types.d.ts +9 -1
  61. package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +1 -1
  62. package/dist/types/modes/theme/theme.d.ts +1 -1
  63. package/dist/types/modes/types.d.ts +3 -1
  64. package/dist/types/modes/utils/context-usage.d.ts +12 -2
  65. package/dist/types/plan-mode/plan-files.d.ts +10 -0
  66. package/dist/types/registry/agent-lifecycle.d.ts +21 -9
  67. package/dist/types/registry/agent-registry.d.ts +11 -4
  68. package/dist/types/sdk.d.ts +15 -1
  69. package/dist/types/secrets/index.d.ts +15 -2
  70. package/dist/types/secrets/obfuscator.d.ts +113 -15
  71. package/dist/types/session/acp-permission-gate.d.ts +19 -0
  72. package/dist/types/session/agent-session-events.d.ts +84 -0
  73. package/dist/types/session/agent-session-types.d.ts +314 -0
  74. package/dist/types/session/agent-session.d.ts +120 -750
  75. package/dist/types/session/async-job-delivery.d.ts +28 -0
  76. package/dist/types/session/bash-runner.d.ts +75 -0
  77. package/dist/types/session/blob-store.d.ts +11 -1
  78. package/dist/types/session/blob-store.test.d.ts +1 -0
  79. package/dist/types/session/checkpoint-entries.d.ts +28 -0
  80. package/dist/types/session/eval-runner.d.ts +49 -0
  81. package/dist/types/session/irc-bridge.d.ts +46 -0
  82. package/dist/types/session/messages.d.ts +22 -0
  83. package/dist/types/session/model-controls.d.ts +180 -0
  84. package/dist/types/session/prewalk.d.ts +46 -0
  85. package/dist/types/session/queued-messages.d.ts +22 -0
  86. package/dist/types/session/redis-session-storage.d.ts +1 -0
  87. package/dist/types/session/retry-fallback-chains.d.ts +74 -0
  88. package/dist/types/session/role-models.d.ts +15 -0
  89. package/dist/types/session/session-advisors.d.ts +228 -0
  90. package/dist/types/session/session-entries.d.ts +8 -0
  91. package/dist/types/session/session-handoff.d.ts +70 -0
  92. package/dist/types/session/session-history-format.d.ts +6 -1
  93. package/dist/types/session/session-loader.d.ts +6 -4
  94. package/dist/types/session/session-maintenance.d.ts +246 -0
  95. package/dist/types/session/session-manager.d.ts +41 -0
  96. package/dist/types/session/session-memory.d.ts +56 -0
  97. package/dist/types/session/session-provider-boundary.d.ts +62 -0
  98. package/dist/types/session/session-stats.d.ts +48 -0
  99. package/dist/types/session/session-tools.d.ts +143 -0
  100. package/dist/types/session/session-workspace.d.ts +32 -0
  101. package/dist/types/session/stream-guards.d.ts +44 -0
  102. package/dist/types/session/todo-tracker.d.ts +56 -0
  103. package/dist/types/session/ttsr-coordinator.d.ts +51 -0
  104. package/dist/types/session/turn-recovery.d.ts +225 -0
  105. package/dist/types/system-prompt.d.ts +2 -0
  106. package/dist/types/task/executor.d.ts +4 -1
  107. package/dist/types/task/output-manager.d.ts +3 -1
  108. package/dist/types/task/types.d.ts +14 -0
  109. package/dist/types/tools/approval.d.ts +1 -0
  110. package/dist/types/tools/browser/launch.d.ts +5 -0
  111. package/dist/types/tools/hub/jobs.d.ts +1 -1
  112. package/dist/types/tools/image-gen.d.ts +4 -4
  113. package/dist/types/tools/image-providers.d.ts +38 -0
  114. package/dist/types/tools/index.d.ts +8 -0
  115. package/dist/types/tools/path-utils.d.ts +8 -0
  116. package/dist/types/tools/report-tool-issue.d.ts +24 -9
  117. package/dist/types/tools/todo.d.ts +10 -4
  118. package/dist/types/tools/xdev.d.ts +5 -1
  119. package/dist/types/tts/streaming-player.d.ts +14 -0
  120. package/dist/types/tts/vocalizer.d.ts +5 -0
  121. package/dist/types/utils/jj.d.ts +29 -0
  122. package/dist/types/utils/lang-from-path.d.ts +1 -0
  123. package/dist/types/utils/title-generator.d.ts +30 -0
  124. package/dist/types/vibe/runtime.d.ts +36 -8
  125. package/dist/types/web/search/index.d.ts +1 -1
  126. package/dist/types/web/search/provider.d.ts +15 -5
  127. package/dist/types/web/search/providers/firecrawl.d.ts +9 -0
  128. package/dist/types/web/search/types.d.ts +95 -1
  129. package/package.json +13 -12
  130. package/scripts/generate-share-viewer.ts +4 -6
  131. package/scripts/legacy-pi-virtual-module.ts +1 -1
  132. package/src/advisor/__tests__/advisor.test.ts +643 -0
  133. package/src/advisor/runtime.ts +144 -43
  134. package/src/async/job-manager.ts +90 -2
  135. package/src/cli/args.ts +2 -0
  136. package/src/cli/auth-gateway-cli.ts +18 -3
  137. package/src/cli/bench-cli.ts +395 -29
  138. package/src/cli/flag-tables.ts +3 -0
  139. package/src/cli/gc-cli.ts +2 -2
  140. package/src/cli/grievances-cli.ts +2 -2
  141. package/src/cli/usage-cli.ts +11 -2
  142. package/src/collab/host.ts +3 -2
  143. package/src/commands/bench.ts +18 -4
  144. package/src/commands/launch.ts +4 -0
  145. package/src/config/__tests__/model-registry.test.ts +147 -0
  146. package/src/config/model-registry.ts +50 -2
  147. package/src/config/model-resolver.ts +0 -1
  148. package/src/config/models-config-schema.ts +15 -4
  149. package/src/config/provider-globals.ts +9 -9
  150. package/src/config/service-tier.ts +26 -1
  151. package/src/config/settings-schema.ts +250 -54
  152. package/src/config/settings.ts +81 -2
  153. package/src/dap/client.ts +37 -4
  154. package/src/edit/index.ts +49 -12
  155. package/src/eval/py/executor.ts +103 -33
  156. package/src/exec/bash-executor.ts +89 -6
  157. package/src/exec/direnv.ts +145 -0
  158. package/src/export/html/args.ts +20 -0
  159. package/src/export/html/index.ts +45 -47
  160. package/src/export/html/template.css +19 -1
  161. package/src/export/html/template.html +6 -0
  162. package/src/export/html/template.js +21 -0
  163. package/src/export/html/tool-views.generated.js +31 -31
  164. package/src/export/html/web-palette.ts +116 -132
  165. package/src/export/share.ts +249 -49
  166. package/src/extensibility/extensions/loader.ts +29 -1
  167. package/src/extensibility/extensions/runner.ts +6 -0
  168. package/src/extensibility/extensions/types.ts +33 -2
  169. package/src/extensibility/legacy-pi-coding-agent-shim.ts +1 -0
  170. package/src/extensibility/legacy-pi-tui-shim.ts +10 -0
  171. package/src/extensibility/plugins/legacy-pi-compat.ts +870 -311
  172. package/src/hindsight/state.ts +64 -6
  173. package/src/internal-urls/local-protocol.ts +2 -1
  174. package/src/internal-urls/memory-protocol.ts +2 -2
  175. package/src/internal-urls/registry-helpers.ts +40 -0
  176. package/src/internal-urls/skill-protocol.ts +2 -2
  177. package/src/internal-urls/ssh-protocol.ts +2 -1
  178. package/src/internal-urls/vault-protocol.ts +2 -1
  179. package/src/live/audio-worklet.txt +59 -0
  180. package/src/live/browser-runtime.txt +221 -0
  181. package/src/live/controller.ts +464 -0
  182. package/src/live/prompts/agent-final-message.md +3 -0
  183. package/src/live/prompts/live-instructions.md +23 -0
  184. package/src/live/protocol.test.ts +140 -0
  185. package/src/live/protocol.ts +233 -0
  186. package/src/live/transport.ts +490 -0
  187. package/src/live/visualizer.ts +234 -0
  188. package/src/lsp/client.ts +52 -9
  189. package/src/lsp/clients/biome-client.ts +3 -4
  190. package/src/lsp/index.ts +44 -11
  191. package/src/main.ts +9 -1
  192. package/src/mcp/config-writer.test.ts +43 -0
  193. package/src/mcp/config-writer.ts +109 -82
  194. package/src/mcp/manager.ts +39 -8
  195. package/src/mcp/oauth-discovery.ts +10 -2
  196. package/src/mcp/render.ts +94 -35
  197. package/src/mcp/smithery-auth.test.ts +29 -0
  198. package/src/mcp/smithery-auth.ts +3 -2
  199. package/src/mcp/tool-bridge.ts +107 -11
  200. package/src/mcp/types.ts +7 -0
  201. package/src/memories/index.ts +40 -20
  202. package/src/memory-backend/tool-names.ts +2 -0
  203. package/src/mnemopi/backend.ts +24 -7
  204. package/src/modes/acp/acp-agent.ts +56 -10
  205. package/src/modes/acp/acp-event-mapper.ts +8 -1
  206. package/src/modes/components/agent-hub.ts +27 -10
  207. package/src/modes/components/login-dialog.ts +14 -4
  208. package/src/modes/components/oauth-selector.ts +24 -7
  209. package/src/modes/components/plan-review-overlay.ts +350 -35
  210. package/src/modes/components/settings-defs.ts +28 -3
  211. package/src/modes/components/settings-selector.ts +178 -6
  212. package/src/modes/components/status-line/component.jj-cache.test.ts +229 -0
  213. package/src/modes/components/status-line/component.ts +123 -2
  214. package/src/modes/components/tool-execution.test.ts +63 -2
  215. package/src/modes/components/tool-execution.ts +11 -2
  216. package/src/modes/controllers/command-controller.ts +70 -22
  217. package/src/modes/controllers/event-controller.ts +111 -16
  218. package/src/modes/controllers/extension-ui-controller.test.ts +1 -0
  219. package/src/modes/controllers/extension-ui-controller.ts +20 -4
  220. package/src/modes/controllers/live-command-controller.ts +178 -0
  221. package/src/modes/controllers/mcp-command-controller.ts +80 -41
  222. package/src/modes/controllers/omfg-controller.ts +44 -40
  223. package/src/modes/controllers/selector-controller.ts +18 -10
  224. package/src/modes/controllers/session-focus-controller.ts +6 -1
  225. package/src/modes/interactive-mode.ts +160 -38
  226. package/src/modes/rpc/rpc-client.ts +94 -3
  227. package/src/modes/rpc/rpc-frame.ts +164 -4
  228. package/src/modes/rpc/rpc-messages.ts +127 -0
  229. package/src/modes/rpc/rpc-mode.ts +79 -7
  230. package/src/modes/rpc/rpc-types.ts +34 -2
  231. package/src/modes/runtime-init.ts +2 -0
  232. package/src/modes/setup-wizard/scenes/model.ts +5 -7
  233. package/src/modes/setup-wizard/scenes/providers.ts +3 -2
  234. package/src/modes/setup-wizard/scenes/sign-in.ts +8 -2
  235. package/src/modes/setup-wizard/scenes/theme.ts +13 -3
  236. package/src/modes/setup-wizard/scenes/types.ts +9 -1
  237. package/src/modes/setup-wizard/scenes/web-search.ts +23 -10
  238. package/src/modes/setup-wizard/wizard-overlay.ts +1 -1
  239. package/src/modes/theme/theme.ts +1 -1
  240. package/src/modes/types.ts +3 -1
  241. package/src/modes/utils/context-usage.ts +23 -7
  242. package/src/modes/utils/ui-helpers.ts +10 -5
  243. package/src/plan-mode/plan-files.ts +40 -0
  244. package/src/prompts/bench/cache-prefix-chunk.md +1 -0
  245. package/src/prompts/bench/cache-prefix.md +3 -0
  246. package/src/prompts/bench/cache-suffix.md +1 -0
  247. package/src/prompts/goals/guided-goal-system.md +24 -3
  248. package/src/prompts/system/custom-system-prompt.md +1 -1
  249. package/src/prompts/system/project-prompt.md +8 -1
  250. package/src/prompts/system/subagent-async-pending.md +6 -0
  251. package/src/prompts/system/system-prompt.md +1 -1
  252. package/src/prompts/system/workflow-notice.md +48 -40
  253. package/src/prompts/system/xdev-mount-notice.md +4 -0
  254. package/src/prompts/tools/task.md +12 -2
  255. package/src/prompts/tools/todo.md +3 -1
  256. package/src/registry/agent-lifecycle.ts +116 -46
  257. package/src/registry/agent-registry.ts +35 -8
  258. package/src/registry/persisted-agents.ts +26 -2
  259. package/src/sdk.ts +255 -139
  260. package/src/secrets/index.ts +127 -4
  261. package/src/secrets/obfuscator.ts +2328 -105
  262. package/src/session/acp-permission-gate.ts +165 -0
  263. package/src/session/agent-session-error-log.test.ts +1 -1
  264. package/src/session/agent-session-events.ts +66 -0
  265. package/src/session/agent-session-types.ts +334 -0
  266. package/src/session/agent-session.ts +1910 -12213
  267. package/src/session/artifacts.ts +6 -5
  268. package/src/session/async-job-delivery.ts +74 -0
  269. package/src/session/bash-runner.ts +326 -0
  270. package/src/session/blob-store.test.ts +56 -0
  271. package/src/session/blob-store.ts +18 -2
  272. package/src/session/checkpoint-entries.ts +81 -0
  273. package/src/session/eval-runner.ts +212 -0
  274. package/src/session/indexed-session-storage.ts +9 -2
  275. package/src/session/irc-bridge.ts +203 -0
  276. package/src/session/messages.ts +263 -1
  277. package/src/session/model-controls.ts +714 -0
  278. package/src/session/prewalk.ts +252 -0
  279. package/src/session/queued-messages.ts +93 -0
  280. package/src/session/redis-session-storage.ts +51 -11
  281. package/src/session/retry-fallback-chains.ts +455 -0
  282. package/src/session/role-models.ts +85 -0
  283. package/src/session/session-advisors.ts +1679 -0
  284. package/src/session/session-context.test.ts +224 -1
  285. package/src/session/session-context.ts +47 -5
  286. package/src/session/session-entries.ts +8 -0
  287. package/src/session/session-handoff.ts +308 -0
  288. package/src/session/session-history-format.ts +20 -9
  289. package/src/session/session-loader.ts +10 -51
  290. package/src/session/session-maintenance.ts +2983 -0
  291. package/src/session/session-manager.ts +390 -30
  292. package/src/session/session-memory.ts +222 -0
  293. package/src/session/session-provider-boundary.ts +307 -0
  294. package/src/session/session-stats.ts +293 -0
  295. package/src/session/session-tools.ts +943 -0
  296. package/src/session/session-workspace.ts +53 -0
  297. package/src/session/stream-guards.ts +417 -0
  298. package/src/session/todo-tracker.ts +380 -0
  299. package/src/session/ttsr-coordinator.ts +496 -0
  300. package/src/session/turn-recovery.ts +1629 -0
  301. package/src/slash-commands/builtin-registry.ts +92 -11
  302. package/src/slash-commands/helpers/usage-report.ts +25 -4
  303. package/src/system-prompt.ts +41 -17
  304. package/src/task/executor.ts +288 -70
  305. package/src/task/index.ts +148 -111
  306. package/src/task/output-manager.ts +25 -3
  307. package/src/task/persisted-revive.ts +4 -3
  308. package/src/task/structured-subagent.ts +4 -1
  309. package/src/task/types.ts +16 -0
  310. package/src/tools/approval.ts +55 -10
  311. package/src/tools/bash-interactive.ts +26 -0
  312. package/src/tools/bash-skill-urls.ts +28 -2
  313. package/src/tools/bash.ts +339 -123
  314. package/src/tools/browser/launch.ts +9 -1
  315. package/src/tools/browser/tab-supervisor.ts +8 -5
  316. package/src/tools/browser.ts +94 -47
  317. package/src/tools/hub/index.ts +1 -1
  318. package/src/tools/hub/jobs.ts +67 -8
  319. package/src/tools/image-gen.ts +20 -28
  320. package/src/tools/image-providers.ts +50 -0
  321. package/src/tools/index.ts +12 -0
  322. package/src/tools/path-utils.ts +49 -2
  323. package/src/tools/read.ts +156 -88
  324. package/src/tools/renderers.ts +7 -1
  325. package/src/tools/report-tool-issue.ts +79 -28
  326. package/src/tools/todo.ts +101 -11
  327. package/src/tools/vibe.ts +1 -2
  328. package/src/tools/write.ts +51 -1
  329. package/src/tools/xdev.ts +67 -3
  330. package/src/tts/streaming-player.ts +62 -4
  331. package/src/tts/vocalizer.ts +18 -1
  332. package/src/utils/jj.ts +125 -4
  333. package/src/utils/lang-from-path.ts +7 -0
  334. package/src/utils/title-generator.ts +137 -1
  335. package/src/vibe/runtime.ts +852 -72
  336. package/src/web/search/index.ts +3 -6
  337. package/src/web/search/provider.ts +31 -16
  338. package/src/web/search/providers/firecrawl.ts +46 -13
  339. package/src/web/search/providers/xai.ts +7 -1
  340. package/src/web/search/types.ts +8 -1
@@ -0,0 +1,1679 @@
1
+ import {
2
+ Agent,
3
+ type AgentMessage,
4
+ type AgentTool,
5
+ AppendOnlyContextManager,
6
+ type CompactionSummaryMessage,
7
+ countTokens,
8
+ resolveTelemetry,
9
+ type StreamFn,
10
+ ThinkingLevel,
11
+ } from "@oh-my-pi/pi-agent-core";
12
+ import {
13
+ type CompactionResult,
14
+ calculateContextTokens,
15
+ compact,
16
+ compactionContextTokens,
17
+ createCompactionSummaryMessage,
18
+ estimateTokens,
19
+ prepareCompaction,
20
+ type SessionMessageEntry,
21
+ shouldCompact,
22
+ } from "@oh-my-pi/pi-agent-core/compaction";
23
+ import type {
24
+ AssistantMessage,
25
+ CodexCompactionContext,
26
+ Context,
27
+ Message,
28
+ Model,
29
+ ProviderSessionState,
30
+ ServiceTier,
31
+ SimpleStreamOptions,
32
+ } from "@oh-my-pi/pi-ai";
33
+ import { isUsageLimitOutcome, resolveModelServiceTier } from "@oh-my-pi/pi-ai";
34
+ import * as AIError from "@oh-my-pi/pi-ai/error";
35
+ import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models";
36
+ import { extractHttpStatusFromError, extractRetryHint, logger } from "@oh-my-pi/pi-utils";
37
+ import {
38
+ ADVISOR_DEFAULT_TOOL_NAMES,
39
+ AdviseTool,
40
+ type AdvisorAgent,
41
+ type AdvisorConfig,
42
+ AdvisorEmissionGuard,
43
+ type AdvisorMessageDetails,
44
+ type AdvisorNote,
45
+ AdvisorOutputQuarantinedError,
46
+ AdvisorRuntime,
47
+ type AdvisorRuntimeStatus,
48
+ type AdvisorSeverity,
49
+ AdvisorTranscriptRecorder,
50
+ advisorTranscriptFilename,
51
+ annotateForStaleness,
52
+ buildAdvisorQuarantineSourceText,
53
+ formatAdvisorBatchContent,
54
+ getOrCreateAdvisorProviderSessionId,
55
+ isAdvisorInterruptImmuneTurnActive,
56
+ isInterruptingSeverity,
57
+ quarantineAdvisorUnsafeOutput,
58
+ resolveAdvisorDeliveryChannel,
59
+ slugifyAdvisorName,
60
+ } from "../advisor";
61
+ import type { ModelRegistry } from "../config/model-registry";
62
+ import {
63
+ formatModelString,
64
+ formatModelStringWithRouting,
65
+ resolveAdvisorRoleSelection,
66
+ resolveModelOverride,
67
+ } from "../config/model-resolver";
68
+ import { MODEL_ROLES } from "../config/model-roles";
69
+ import { serviceTierForAllFamilies, serviceTierSettingToTier } from "../config/service-tier";
70
+ import type { Settings } from "../config/settings";
71
+ import { CursorExecHandlers } from "../cursor";
72
+ import { estimateToolSchemaTokens } from "../modes/utils/context-usage";
73
+ import type { PlanModeState } from "../plan-mode/state";
74
+ import advisorSystemPrompt from "../prompts/advisor/system.md" with { type: "text" };
75
+ import type { SecretObfuscator } from "../secrets/obfuscator";
76
+ import {
77
+ concreteThinkingLevel,
78
+ resolveThinkingLevelForModel,
79
+ shouldDisableReasoning,
80
+ toReasoningEffort,
81
+ } from "../thinking";
82
+ import type { AgentSessionEvent } from "./agent-session-events";
83
+ import type { ClientBridge } from "./client-bridge";
84
+ import type { CustomMessage, CustomMessagePayload } from "./messages";
85
+ import { isAdvisorCard, isTerminalTextAssistantAnswer } from "./queued-messages";
86
+ import {
87
+ formatRetryFallbackSelector,
88
+ getRetryFallbackRevertPolicy,
89
+ parseRetryFallbackSelector,
90
+ type RetryFallbackSelector,
91
+ } from "./retry-fallback-chains";
92
+ import { formatSessionDumpText } from "./session-dump-format";
93
+ import type { CompactionEntry, SessionEntry } from "./session-entries";
94
+ import { formatSessionHistoryMarkdown } from "./session-history-format";
95
+ import type { SessionManager } from "./session-manager";
96
+ import type { YieldQueue } from "./yield-queue";
97
+
98
+ /** Advisor statistics for the advisor status command. */
99
+ export interface AdvisorStats {
100
+ configured: boolean;
101
+ active: boolean;
102
+ model?: Model;
103
+ contextWindow: number;
104
+ contextTokens: number;
105
+ tokens: {
106
+ input: number;
107
+ output: number;
108
+ reasoning: number;
109
+ cacheRead: number;
110
+ cacheWrite: number;
111
+ total: number;
112
+ };
113
+ cost: number;
114
+ messages: {
115
+ user: number;
116
+ assistant: number;
117
+ total: number;
118
+ };
119
+ /** Per-advisor breakdown for every configured advisor. */
120
+ advisors: PerAdvisorStat[];
121
+ }
122
+
123
+ /** One advisor's status, token usage, cost, and message counts. */
124
+ export interface PerAdvisorStat {
125
+ name: string;
126
+ status: AdvisorRuntimeStatus;
127
+ model?: Model;
128
+ contextWindow: number;
129
+ contextTokens: number;
130
+ tokens: AdvisorStats["tokens"];
131
+ cost: number;
132
+ messages: AdvisorStats["messages"];
133
+ sessionId?: string;
134
+ }
135
+
136
+ interface AdvisorRetryFallbackState {
137
+ role: string;
138
+ originalSelector: string;
139
+ originalThinkingLevel: ThinkingLevel;
140
+ lastAppliedThinkingLevel: ThinkingLevel;
141
+ }
142
+
143
+ interface ActiveAdvisor {
144
+ name: string;
145
+ slug: string;
146
+ agent: Agent;
147
+ runtime: AdvisorRuntime;
148
+ adviseTool: AdviseTool;
149
+ emissionGuard: AdvisorEmissionGuard;
150
+ recorder: AdvisorTranscriptRecorder;
151
+ recorderClosed: Promise<void>;
152
+ agentUnsubscribe?: () => void;
153
+ model: Model;
154
+ thinkingLevel: ThinkingLevel;
155
+ providerSessionId: string | undefined;
156
+ retryFallback?: AdvisorRetryFallbackState;
157
+ retryFallbackPendingSuccess: boolean;
158
+ signature: string;
159
+ }
160
+
161
+ interface AdvisorCompactionSummaryMessage extends CompactionSummaryMessage {
162
+ firstKeptEntryId?: string;
163
+ advisorUsageAnchorStartIndex?: number;
164
+ }
165
+
166
+ interface AdvisorRuntimeDescriptor {
167
+ config: AdvisorConfig;
168
+ name: string;
169
+ slug: string;
170
+ model: Model;
171
+ thinkingLevel: ThinkingLevel;
172
+ signature: string;
173
+ }
174
+
175
+ /** Inputs that configure the advisor roster owned by a session. */
176
+ export interface SessionAdvisorsOptions {
177
+ enabled: boolean;
178
+ tools?: AgentTool[];
179
+ watchdogPrompt?: string;
180
+ sharedInstructions?: string;
181
+ contextPrompt?: string;
182
+ configs?: AdvisorConfig[];
183
+ streamFn?: StreamFn;
184
+ transformProviderContext?: (context: Context, model: Model) => Context | Promise<Context>;
185
+ }
186
+
187
+ /** Options accepted when an advisor injects a primary-session message. */
188
+ export interface AdvisorMessageDeliveryOptions {
189
+ triggerTurn?: boolean;
190
+ deliverAs?: "steer" | "followUp" | "nextTurn";
191
+ queueChipText?: string;
192
+ acceptTerminalEmptyStop?: boolean;
193
+ }
194
+
195
+ /** Session capabilities borrowed by the advisor controller. */
196
+ export interface SessionAdvisorsHost {
197
+ agent: Agent;
198
+ sessionManager: SessionManager;
199
+ settings: Settings;
200
+ modelRegistry: ModelRegistry;
201
+ yieldQueue: YieldQueue;
202
+ obfuscator: SecretObfuscator | undefined;
203
+ providerSessionState: Map<string, ProviderSessionState>;
204
+ preferWebsockets: boolean | undefined;
205
+ onPayload: SimpleStreamOptions["onPayload"] | undefined;
206
+ onResponse: SimpleStreamOptions["onResponse"] | undefined;
207
+ onSseEvent: SimpleStreamOptions["onSseEvent"] | undefined;
208
+ agentKind(): "main" | "sub";
209
+ isDisposed(): boolean;
210
+ abortInProgress(): boolean;
211
+ allowAgentInitiatedTurns(): boolean;
212
+ planModeState(): PlanModeState | undefined;
213
+ clientBridge(): ClientBridge | undefined;
214
+ emitSessionEvent(event: AgentSessionEvent): Promise<void>;
215
+ emitNotice(level: "info" | "warning" | "error", message: string, source?: string): void;
216
+ sendCustomMessage(message: CustomMessagePayload, options?: AdvisorMessageDeliveryOptions): Promise<boolean>;
217
+ extractQueuedAdvisorCards(): CustomMessage[];
218
+ dropPendingAdvisorCards(): void;
219
+ preserveAdvisorCard(card: CustomMessage): void;
220
+ hasPendingNextTurnMessages(): boolean;
221
+ convertToLlmForSideRequest(messages: AgentMessage[]): Message[];
222
+ effectiveServiceTier(model: Model): ServiceTier | undefined;
223
+ resolveContextPromotionTarget(currentModel: Model, contextWindow: number): Promise<Model | undefined>;
224
+ resolveCompactionModelCandidates(preferredModel: Model | null | undefined, availableModels: Model[]): Model[];
225
+ resolveRetryFallbackRole(currentSelector: string, currentModel?: Model | null): string | undefined;
226
+ findRetryFallbackCandidates(
227
+ role: string,
228
+ currentSelector: string,
229
+ currentModel?: Model | null,
230
+ ): RetryFallbackSelector[];
231
+ isRetryFallbackSelectorSuppressed(selector: RetryFallbackSelector): boolean;
232
+ noteRetryFallbackCooldown(currentSelector: string, retryAfterMs: number | undefined, errorMessage: string): void;
233
+ createCodexCompactionContext(options: {
234
+ trigger: CodexCompactionContext["trigger"];
235
+ reason: CodexCompactionContext["reason"];
236
+ phase: CodexCompactionContext["phase"];
237
+ }): CodexCompactionContext;
238
+ sessionId(): string;
239
+ }
240
+
241
+ /** Owns advisor runtimes, delivery policy, context maintenance, and status reporting. */
242
+ export class SessionAdvisors {
243
+ readonly #host: SessionAdvisorsHost;
244
+ #advisorEnabled: boolean;
245
+ #advisorTools: AgentTool[] | undefined;
246
+ #advisorWatchdogPrompt: string | undefined;
247
+ #advisorSharedInstructions: string | undefined;
248
+ #advisorContextPrompt: string | undefined;
249
+ #advisorStreamFn: StreamFn | undefined;
250
+ #transformProviderContext: ((context: Context, model: Model) => Context | Promise<Context>) | undefined;
251
+ #advisors: ActiveAdvisor[] = [];
252
+ #advisorConfigs: AdvisorConfig[] | undefined;
253
+ #advisorStatuses = new Map<string, { name: string; status: AdvisorRuntimeStatus }>();
254
+ #advisorProviderSessionIds = new Map<string, string>();
255
+ #advisorRecorderClosed: Promise<void> = Promise.resolve();
256
+ #advisorAutoResumeSuppressed = false;
257
+ #preserveAdvisorAdvice = false;
258
+ #advisorPrimaryTurnsCompleted = 0;
259
+ #advisorInterruptImmuneTurnStart: number | undefined;
260
+ #pendingAdvisorCardEvents = new Set<Promise<void>>();
261
+ #advisorYieldQueueUnsubscribe: (() => void) | undefined;
262
+
263
+ constructor(host: SessionAdvisorsHost, options: SessionAdvisorsOptions) {
264
+ this.#host = host;
265
+ this.#advisorEnabled = options.enabled;
266
+ this.#advisorTools = options.tools;
267
+ this.#advisorWatchdogPrompt = options.watchdogPrompt;
268
+ this.#advisorSharedInstructions = options.sharedInstructions;
269
+ this.#advisorContextPrompt = options.contextPrompt;
270
+ this.#advisorConfigs = options.configs;
271
+ this.#advisorStreamFn = options.streamFn;
272
+ this.#transformProviderContext = options.transformProviderContext;
273
+ if (this.#advisorEnabled) this.#buildAdvisorRuntime();
274
+ }
275
+
276
+ /** Delivers one completed primary turn to every live advisor. */
277
+ async onPrimaryTurnEnd(
278
+ messages: AgentMessage[],
279
+ willContinue: boolean | undefined,
280
+ signal?: AbortSignal,
281
+ ): Promise<void> {
282
+ this.#advisorPrimaryTurnsCompleted++;
283
+ for (const advisor of this.#advisors) {
284
+ if (advisor.runtime.disposed) continue;
285
+ try {
286
+ advisor.runtime.onTurnEnd(messages, { willContinue });
287
+ } catch (error) {
288
+ logger.warn("advisor onTurnEnd threw; delta dropped", { advisor: advisor.name, err: String(error) });
289
+ }
290
+ }
291
+ const syncBacklog = this.#host.settings.get("advisor.syncBacklog");
292
+ if (this.#advisors.length === 0 || syncBacklog === "off") return;
293
+ const threshold = Number.parseInt(syncBacklog, 10);
294
+ await Promise.all(this.#advisors.map(advisor => advisor.runtime.waitForCatchup(30_000, threshold, signal)));
295
+ }
296
+
297
+ /** Rebuilds live advisors when role assignments alter their resolved runtime inputs. */
298
+ onModelRolesChanged(): void {
299
+ if (!this.#advisorEnabled || this.#host.isDisposed()) return;
300
+ if (this.#advisors.length > 0 && !this.#advisorRuntimeMatchesCurrentConfig()) this.#stopAdvisorRuntime();
301
+ this.#buildAdvisorRuntime(true);
302
+ }
303
+
304
+ /** Starts configured advisor runtimes when they are eligible. */
305
+ buildRuntime(seedToCurrent = false): boolean {
306
+ return this.#buildAdvisorRuntime(seedToCurrent);
307
+ }
308
+
309
+ /** Stops every advisor runtime and starts recorder shutdown. */
310
+ stopRuntime(): void {
311
+ this.#stopAdvisorRuntime();
312
+ }
313
+
314
+ /** Detaches and drains recorder feeds before transcript artifacts are removed. */
315
+ async detachAndCloseRecorders(): Promise<void> {
316
+ const closes: Promise<void>[] = [];
317
+ for (const advisor of this.#advisors) {
318
+ advisor.agentUnsubscribe?.();
319
+ advisor.agentUnsubscribe = undefined;
320
+ advisor.recorderClosed = advisor.recorder.close();
321
+ closes.push(advisor.recorderClosed);
322
+ }
323
+ await Promise.all(closes);
324
+ }
325
+
326
+ /** Re-primes advisor transcript views across a conversation boundary. */
327
+ resetSessionState(): void {
328
+ this.#resetAdvisorSessionState();
329
+ }
330
+
331
+ /** Re-primes advisor transcript views after an in-conversation history rewrite. */
332
+ resetAllRuntimes(): void {
333
+ this.#resetAllAdvisorRuntimes();
334
+ }
335
+
336
+ /** Whether live runtimes still match the resolved advisor configuration. */
337
+ runtimeMatchesCurrentConfig(): boolean {
338
+ return this.#advisorRuntimeMatchesCurrentConfig();
339
+ }
340
+
341
+ /** Whether concern/blocker delivery is inside the post-interrupt immunity window. */
342
+ isInterruptImmuneTurnActive(): boolean {
343
+ return this.#isAdvisorInterruptImmuneTurnActive();
344
+ }
345
+
346
+ /** Latest aggregate recorder-close barrier. */
347
+ recorderClosed(): Promise<void> {
348
+ return this.#advisorRecorderClosed;
349
+ }
350
+
351
+ /** Whether a user interrupt currently suppresses advisor-driven auto-resume. */
352
+ get autoResumeSuppressed(): boolean {
353
+ return this.#advisorAutoResumeSuppressed;
354
+ }
355
+
356
+ set autoResumeSuppressed(value: boolean) {
357
+ this.#advisorAutoResumeSuppressed = value;
358
+ }
359
+
360
+ /** Tracks persistence of a visible advisor card emitted outside the primary loop. */
361
+ trackCardEvent(processing: Promise<void>): void {
362
+ this.#pendingAdvisorCardEvents.add(processing);
363
+ void processing.finally(() => this.#pendingAdvisorCardEvents.delete(processing)).catch(() => {});
364
+ }
365
+
366
+ /** Waits for all advisor-card persistence handlers currently in flight. */
367
+ async waitForPendingCardEvents(): Promise<void> {
368
+ await Promise.allSettled([...this.#pendingAdvisorCardEvents]);
369
+ }
370
+
371
+ // Advisor runtime lifecycle
372
+ // -------------------------------------------------------------------------
373
+ #advisorImmuneTurnLimit(): number {
374
+ const immuneTurns = this.#host.settings.get("advisor.immuneTurns") as number;
375
+ if (!Number.isFinite(immuneTurns) || immuneTurns <= 0) return 0;
376
+ return Math.trunc(immuneTurns);
377
+ }
378
+
379
+ #isAdvisorInterruptImmuneTurnActive(): boolean {
380
+ return isAdvisorInterruptImmuneTurnActive({
381
+ completedTurns: this.#advisorPrimaryTurnsCompleted,
382
+ immuneTurnStart: this.#advisorInterruptImmuneTurnStart,
383
+ immuneTurns: this.#advisorImmuneTurnLimit(),
384
+ });
385
+ }
386
+
387
+ // The next primary turn number starts the immune-turn window. While the
388
+ // interrupting steer is still in flight, completedTurns is lower than this
389
+ // start, so duplicate concern/blocker advice is also downgraded.
390
+ #recordAdvisorInterruptDelivered(): void {
391
+ this.#advisorInterruptImmuneTurnStart = this.#advisorPrimaryTurnsCompleted + 1;
392
+ }
393
+
394
+ /**
395
+ * Re-prime the advisor across a conversation boundary: `/new`, `/branch`,
396
+ * `/btw`, `/tree`, and session switch/resume. Beyond {@link AdvisorRuntime.reset}
397
+ * (which only re-primes the advisor's transcript view and is also fired by
398
+ * within-conversation rewrites like compaction/shake/rewind), this clears the
399
+ * session-level interrupt latches so the prior conversation's cooldown cannot
400
+ * leak into the new one: the post-interrupt immune-turn window
401
+ * (`#advisorPrimaryTurnsCompleted`, `#advisorInterruptImmuneTurnStart`) and the
402
+ * user-interrupt auto-resume suppression flag. It also drops advisor deliveries
403
+ * still queued against the prior conversation — pending asides in the yield
404
+ * queue (advisor entries use `skipIdleFlush`, so they linger until the next
405
+ * `drainLazy` rather than self-flushing), interrupting cards parked in the
406
+ * agent steer/follow-up queues, and preserved cards deferred to the next turn —
407
+ * so none of them inject into the new conversation.
408
+ */
409
+ #resetAdvisorSessionState(): void {
410
+ // Mute the recorder across the re-prime: AdvisorRuntime.reset() aborts the advisor
411
+ // loop, and that abort can emit an `aborted` message_end we must not attribute to
412
+ // either session's transcript. Detach, reset, then re-attach the live agent's feed.
413
+ for (const a of this.#advisors) {
414
+ a.agentUnsubscribe?.();
415
+ a.agentUnsubscribe = undefined;
416
+ a.runtime.reset();
417
+ a.adviseTool.resetDeliveredNotes();
418
+ a.emissionGuard.reset();
419
+ this.#attachAdvisorRecorderFeed(a);
420
+ }
421
+ this.#advisorPrimaryTurnsCompleted = 0;
422
+ this.#advisorInterruptImmuneTurnStart = undefined;
423
+ this.#advisorAutoResumeSuppressed = false;
424
+ this.#host.yieldQueue.clear("advisor");
425
+ this.#host.extractQueuedAdvisorCards();
426
+ this.#host.dropPendingAdvisorCards();
427
+ }
428
+
429
+ #resolveAdvisorRuntimeDescriptors(emitWarnings: boolean): AdvisorRuntimeDescriptor[] {
430
+ const legacy = !this.#advisorConfigs?.length;
431
+ const roster: AdvisorConfig[] = legacy ? [{ name: "default" }] : this.#advisorConfigs!;
432
+ const descriptors: AdvisorRuntimeDescriptor[] = [];
433
+ const usedSlugs = new Set<string>();
434
+ for (const config of roster) {
435
+ let slug = legacy ? "" : slugifyAdvisorName(config.name);
436
+ if (slug) {
437
+ let candidate = slug;
438
+ let n = 2;
439
+ while (usedSlugs.has(candidate)) candidate = `${slug}-${n++}`;
440
+ slug = candidate;
441
+ usedSlugs.add(slug);
442
+ }
443
+ // Per-advisor toggle: skip disabled advisors but keep them in the
444
+ // status map so they show `○` rather than disappearing.
445
+ if (config.enabled === false) {
446
+ this.#advisorStatuses.set(slug, { name: config.name, status: "paused" });
447
+ continue;
448
+ }
449
+
450
+ // Resolve the advisor's model: an explicit `model` override wins; else the
451
+ // `advisor` role chain. A model that fails to resolve skips just this advisor.
452
+ let model: Model | undefined;
453
+ let thinkingLevel: ThinkingLevel | undefined;
454
+ if (config.model) {
455
+ const resolved = resolveModelOverride([config.model], this.#host.modelRegistry, this.#host.settings);
456
+ model = resolved.model;
457
+ thinkingLevel = concreteThinkingLevel(resolved.thinkingLevel);
458
+ if (!model) {
459
+ this.#advisorStatuses.set(slug, { name: config.name, status: "no_model" });
460
+ if (emitWarnings) {
461
+ this.#host.emitNotice(
462
+ "warning",
463
+ `Advisor "${config.name}": no model matched "${config.model}"`,
464
+ "advisor",
465
+ );
466
+ }
467
+ continue;
468
+ }
469
+ } else {
470
+ const sel = resolveAdvisorRoleSelection(this.#host.settings, this.#host.modelRegistry.getAvailable());
471
+ if (!sel) {
472
+ this.#advisorStatuses.set(slug, { name: config.name, status: "no_model" });
473
+ if (emitWarnings) {
474
+ logger.debug("advisor enabled but no model assigned to the 'advisor' role; advisor inactive", {
475
+ advisor: config.name,
476
+ });
477
+ }
478
+ continue;
479
+ }
480
+ model = sel.model;
481
+ thinkingLevel = concreteThinkingLevel(sel.thinkingLevel);
482
+ }
483
+ // Clamp the effort against the resolved model. Historically we defaulted
484
+ // to `ThinkingLevel.Medium` unconditionally, which threw at first stream
485
+ // on reasoning models that expose no controllable effort surface
486
+ // (e.g. `devin-agent`: Cascade routes by sibling model id, not a wire
487
+ // param; `getSupportedEfforts` returns `[]`). `resolveThinkingLevelForModel`
488
+ // preserves an explicit `off`, clamps a concrete effort into the model's
489
+ // supported range, and returns `undefined` for reasoning models without
490
+ // controllable efforts — for that case we forward `Inherit` so no effort
491
+ // is sent and reasoning stays enabled (matching the `auto`-path fix for
492
+ // Devin models via `clampAutoThinkingEffort`). See #4579.
493
+ const requestedLevel = thinkingLevel ?? ThinkingLevel.Medium;
494
+ const resolvedLevel = resolveThinkingLevelForModel(model, requestedLevel);
495
+ const advisorThinkingLevel: ThinkingLevel = resolvedLevel ?? ThinkingLevel.Inherit;
496
+ // Record the status entry now (in roster order) so the Map's insertion
497
+ // order matches the configured roster even when earlier advisors were
498
+ // skipped as paused/no_model. The build loop overwrites this to "running"
499
+ // without changing insertion order.
500
+ this.#advisorStatuses.set(slug, { name: config.name, status: "running" });
501
+ descriptors.push({
502
+ config,
503
+ name: config.name,
504
+ slug,
505
+ model,
506
+ thinkingLevel: advisorThinkingLevel,
507
+ signature: this.#advisorRuntimeSignature(config, slug, model, advisorThinkingLevel),
508
+ });
509
+ }
510
+ return descriptors;
511
+ }
512
+
513
+ #advisorRuntimeSignature(config: AdvisorConfig, slug: string, model: Model, thinkingLevel: ThinkingLevel): string {
514
+ const tools = config.tools?.length ? config.tools.join("\u001e") : "";
515
+ const instructions = config.instructions?.trim() ?? "";
516
+ return [config.name, slug, formatModelStringWithRouting(model), thinkingLevel, tools, instructions].join(
517
+ "\u001f",
518
+ );
519
+ }
520
+
521
+ #advisorRuntimeMatchesCurrentConfig(): boolean {
522
+ const descriptors = this.#resolveAdvisorRuntimeDescriptors(false);
523
+ if (descriptors.length !== this.#advisors.length) return false;
524
+ for (let i = 0; i < descriptors.length; i++) {
525
+ if (descriptors[i].signature !== this.#advisors[i].signature) return false;
526
+ }
527
+ return true;
528
+ }
529
+
530
+ #buildAdvisorRuntime(seedToCurrent = false): boolean {
531
+ if (this.#host.isDisposed()) return false;
532
+ if (this.#advisors.length > 0) return true;
533
+ if (!this.#advisorEnabled) return false;
534
+ if (this.#host.agentKind() !== "main" && !this.#host.settings.get("advisor.subagents")) return false;
535
+
536
+ // Rebuild the status map from scratch so removed/renamed advisors don't
537
+ // leave stale entries. #resolveAdvisorRuntimeDescriptors populates every
538
+ // entry (`paused`/`no_model`/`running`) in roster order; the build loop
539
+ // below confirms `running` for successfully built advisors.
540
+ this.#advisorStatuses.clear();
541
+ const descriptors = this.#resolveAdvisorRuntimeDescriptors(true);
542
+
543
+ // Advisor service tier (`tier.advisor`): "none" (default) runs the advisor
544
+ // on standard processing; "inherit" tracks the session's live per-family
545
+ // tiers per request (like the main agent, including /fast toggles); a
546
+ // concrete value is broadcast across families and applied to the advisor
547
+ // model's family. One value for all advisors.
548
+ const advisorTierSetting = this.#host.settings.get("tier.advisor");
549
+ const advisorTierMap =
550
+ advisorTierSetting === "inherit"
551
+ ? undefined
552
+ : serviceTierForAllFamilies(serviceTierSettingToTier(advisorTierSetting));
553
+ const advisorServiceTierResolver = (model: Model): ServiceTier | undefined =>
554
+ advisorTierSetting === "inherit"
555
+ ? this.#host.effectiveServiceTier(model)
556
+ : resolveModelServiceTier(advisorTierMap, model);
557
+
558
+ for (const descriptor of descriptors) {
559
+ const {
560
+ config,
561
+ slug,
562
+ model: advisorModel,
563
+ name: advisorName,
564
+ thinkingLevel: advisorThinkingLevel,
565
+ signature,
566
+ } = descriptor;
567
+
568
+ const emissionGuard = new AdvisorEmissionGuard();
569
+ const adviseTool = new AdviseTool((note, severity) => this.#routeAdvice(advisorRef, note, severity));
570
+
571
+ // `#advisorWatchdogPrompt` already carries WATCHDOG.md + YAML shared
572
+ // instructions; `config.instructions` adds this advisor's specialization.
573
+ const systemPrompt = [advisorSystemPrompt];
574
+ if (this.#advisorContextPrompt) systemPrompt.push(this.#advisorContextPrompt);
575
+ if (this.#advisorWatchdogPrompt) systemPrompt.push(this.#advisorWatchdogPrompt);
576
+ if (this.#advisorSharedInstructions) systemPrompt.push(this.#advisorSharedInstructions);
577
+ if (config.instructions?.trim()) systemPrompt.push(config.instructions.trim());
578
+
579
+ const names = config.tools === undefined ? ADVISOR_DEFAULT_TOOL_NAMES : new Set(config.tools);
580
+ const tools = (this.#advisorTools ?? []).filter(t => names.has(t.name));
581
+ const advisorLoopTools: AgentTool<any>[] = [adviseTool, ...tools];
582
+ const advisorToolMap = new Map<string, AgentTool<any>>();
583
+ const availableAdvisorToolNames = new Set<string>();
584
+ for (const tool of advisorLoopTools) {
585
+ availableAdvisorToolNames.add(tool.name);
586
+ advisorToolMap.set(tool.name, tool);
587
+ if (tool.customWireName !== undefined) {
588
+ availableAdvisorToolNames.add(tool.customWireName);
589
+ advisorToolMap.set(tool.customWireName, tool);
590
+ }
591
+ }
592
+ let quarantinedAdvisorOutput: string | undefined;
593
+ let currentAdvisorInput = "";
594
+
595
+ const primaryProviderSessionId = this.#host.sessionId();
596
+ const advisorSessionLabel = slug
597
+ ? `${primaryProviderSessionId}-advisor-${slug}`
598
+ : `${primaryProviderSessionId}-advisor`;
599
+ const advisorProviderSessionId = getOrCreateAdvisorProviderSessionId(
600
+ this.#advisorProviderSessionIds,
601
+ primaryProviderSessionId,
602
+ slug,
603
+ );
604
+ const appendOnlyContext = new AppendOnlyContextManager();
605
+
606
+ // Thread the primary's telemetry into the advisor loop so the advisor
607
+ // model's GenAI spans + usage/cost hooks fire stamped with the local advisor
608
+ // identity. `conversationId` is cleared so provider telemetry falls back to
609
+ // the UUIDv7 provider session id, not the local `-advisor` label.
610
+ const advisorTelemetry = this.#host.agent.telemetry
611
+ ? {
612
+ ...this.#host.agent.telemetry,
613
+ agent: {
614
+ id: advisorSessionLabel,
615
+ name: slug ? `${MODEL_ROLES.advisor.name}: ${advisorName}` : MODEL_ROLES.advisor.name,
616
+ description: formatModelString(advisorModel),
617
+ },
618
+ conversationId: undefined,
619
+ }
620
+ : undefined;
621
+ // Mirror the SDK's provider-shaping options (streamFn/onPayload/...,
622
+ // providerSessionState, promptCacheKey, transformProviderContext) so each
623
+ // advisor's requests cache, route, and obfuscate like the main turn.
624
+ // `promptCacheKey` preserves an explicitly pinned provider cache key
625
+ // unchanged so tan/shared-session advisor calls read the exact shard the
626
+ // parent turn populated. Otherwise the advisor uses its provider UUIDv7 so
627
+ // Codex request identity remains UUID-shaped while local labels keep the
628
+ // `-advisor` suffix.
629
+ const advisorPromptCacheKey = this.#host.agent.promptCacheKey ?? advisorProviderSessionId;
630
+ // On the Cursor provider every tool runs server-side and is dispatched
631
+ // back through `cursorExecHandlers`; without this bridge the advisor's
632
+ // own tools (including the MCP `advise` tool) return `toolNotFound` and
633
+ // no advice is ever routed (issue #5680). Mirrors the primary agent's
634
+ // bridge (`sdk.ts`), scoped to this advisor's granted tool set.
635
+ // Cursor's native `delete` frame removes files directly, bypassing the
636
+ // tool map, so gate it on the advisor actually holding a file-mutating
637
+ // tool. A default read-only advisor (advise/read/grep/glob) never gets
638
+ // to delete workspace files it was never granted (issue #5680 review).
639
+ const advisorCanMutateFiles = advisorToolMap.has("write") || advisorToolMap.has("edit");
640
+ if (advisorCanMutateFiles) availableAdvisorToolNames.add("delete");
641
+ const advisorCursorExecHandlers = new CursorExecHandlers({
642
+ cwd: this.#host.sessionManager.getCwd(),
643
+ getCwd: () => this.#host.sessionManager.getCwd(),
644
+ tools: advisorToolMap,
645
+ allowNativeDelete: advisorCanMutateFiles,
646
+ });
647
+ const advisorAgent = new Agent({
648
+ initialState: {
649
+ systemPrompt,
650
+ model: advisorModel,
651
+ thinkingLevel: toReasoningEffort(advisorThinkingLevel),
652
+ tools: advisorLoopTools,
653
+ },
654
+ appendOnlyContext,
655
+ sessionId: advisorProviderSessionId,
656
+ promptCacheKey: advisorPromptCacheKey,
657
+ providerSessionState: this.#host.providerSessionState,
658
+ cursorExecHandlers: advisorCursorExecHandlers,
659
+ cwdResolver: () => this.#host.sessionManager.getCwd(),
660
+ preferWebsockets: this.#host.preferWebsockets,
661
+ getApiKey: requestModel => this.#host.modelRegistry.resolver(requestModel, advisorProviderSessionId),
662
+ streamFn: this.#advisorStreamFn,
663
+ onPayload: this.#host.onPayload,
664
+ onResponse: this.#host.onResponse,
665
+ onSseEvent: this.#host.onSseEvent,
666
+ transformProviderContext: this.#transformProviderContext,
667
+ intentTracing: false,
668
+ transformAssistantMessage: message => {
669
+ quarantinedAdvisorOutput = quarantineAdvisorUnsafeOutput(
670
+ message,
671
+ availableAdvisorToolNames,
672
+ buildAdvisorQuarantineSourceText(currentAdvisorInput, advisorAgent.state.messages),
673
+ );
674
+ },
675
+ telemetry: advisorTelemetry,
676
+ serviceTier: undefined,
677
+ serviceTierResolver: advisorServiceTierResolver,
678
+ });
679
+ advisorAgent.setDisableReasoning(shouldDisableReasoning(advisorThinkingLevel));
680
+
681
+ const advisorAgentFacade: AdvisorAgent = {
682
+ prompt: async input => {
683
+ let quarantined: string | undefined;
684
+ try {
685
+ quarantinedAdvisorOutput = undefined;
686
+ currentAdvisorInput = input;
687
+ await advisorAgent.prompt(input);
688
+ quarantined = quarantinedAdvisorOutput;
689
+ } finally {
690
+ quarantinedAdvisorOutput = undefined;
691
+ currentAdvisorInput = "";
692
+ }
693
+ if (quarantined) throw new AdvisorOutputQuarantinedError(quarantined);
694
+ },
695
+ abort: reason => advisorAgent.abort(reason),
696
+ reset: () => {
697
+ advisorAgent.reset();
698
+ appendOnlyContext.log.clear();
699
+ },
700
+ rollbackTo: count => {
701
+ // Drop the failed user batch + synthetic assistant-error turn
702
+ // `Agent.#runLoop` appended for a turn ending in `stopReason: "error"`.
703
+ const messages = advisorAgent.state.messages;
704
+ if (count < messages.length) {
705
+ messages.length = count;
706
+ }
707
+ appendOnlyContext.resetSyncCursor();
708
+ advisorAgent.state.error = undefined;
709
+ },
710
+ state: advisorAgent.state,
711
+ };
712
+
713
+ // Persist this advisor's turns to `<session>/__advisor[.<slug>].jsonl`
714
+ // (resolved lazily so it follows session switches) for stats attribution
715
+ // and Agent Hub observability, without registering it as a peer.
716
+ const recorder = new AdvisorTranscriptRecorder(
717
+ () => this.#host.sessionManager.getSessionFile(),
718
+ () => this.#host.sessionManager.getCwd(),
719
+ advisorTranscriptFilename(slug),
720
+ // On the advisor on→off→on toggle, wait for the prior recorders' closes
721
+ // so two SessionManagers never hold the same file at once.
722
+ this.#advisorRecorderClosed,
723
+ );
724
+ const runtime = new AdvisorRuntime(advisorAgentFacade, {
725
+ snapshotMessages: () => this.#host.agent.state.messages,
726
+ enqueueAdvice: (note, severity) => this.#routeAdvice(advisorRef, note, severity),
727
+ maintainContext: incomingTokens => this.#maintainAdvisorContext(advisorRef, incomingTokens),
728
+ obfuscator: this.#host.obfuscator,
729
+ beginAdvisorUpdate: () => advisorRef.emissionGuard.beginUpdate(),
730
+ onTurnError: (error, failedMessages) => this.#recoverAdvisorTurn(advisorRef, error, failedMessages),
731
+ onTurnSuccess: async () => {
732
+ const fallback = advisorRef.retryFallback;
733
+ if (!advisorRef.retryFallbackPendingSuccess || !fallback) return;
734
+ advisorRef.retryFallbackPendingSuccess = false;
735
+ await this.#host.emitSessionEvent({
736
+ type: "retry_fallback_succeeded",
737
+ model: formatRetryFallbackSelector(advisorRef.agent.state.model, advisorRef.thinkingLevel),
738
+ role: fallback.role,
739
+ });
740
+ },
741
+ notifyFailure: error => {
742
+ this.#advisorStatuses.set(slug, { name: advisorName, status: "error" });
743
+ const message = error instanceof Error ? error.message : String(error);
744
+ this.#host.emitNotice(
745
+ "warning",
746
+ `Advisor${slug ? ` "${advisorName}"` : ""} unavailable for ${formatModelString(advisorAgent.state.model)}: ${message}`,
747
+ "advisor",
748
+ );
749
+ },
750
+ notifyQuotaExhausted: () => {
751
+ this.#advisorStatuses.set(slug, { name: advisorName, status: "quota_exhausted" });
752
+ this.#host.emitNotice(
753
+ "warning",
754
+ `Advisor "${advisorName}" quota exhausted — pausing until reset.`,
755
+ "advisor",
756
+ );
757
+ },
758
+ });
759
+
760
+ const advisorRef: ActiveAdvisor = {
761
+ name: advisorName,
762
+ slug,
763
+ agent: advisorAgent,
764
+ runtime,
765
+ adviseTool,
766
+ emissionGuard,
767
+ recorder,
768
+ recorderClosed: Promise.resolve(),
769
+ model: advisorModel,
770
+ thinkingLevel: advisorThinkingLevel,
771
+ providerSessionId: advisorProviderSessionId,
772
+ retryFallbackPendingSuccess: false,
773
+ signature,
774
+ };
775
+ this.#attachAdvisorRecorderFeed(advisorRef);
776
+ if (seedToCurrent) runtime.seedTo(this.#host.agent.state.messages.length);
777
+ this.#advisorStatuses.set(slug, { name: advisorName, status: "running" });
778
+ this.#advisors.push(advisorRef);
779
+ }
780
+
781
+ // One shared non-blocking aside channel for all advisors; the build callback
782
+ // aggregates every advisor's queued nits into one card (each entry already
783
+ // carries its own `advisor` name).
784
+ if (this.#advisors.length > 0 && !this.#advisorYieldQueueUnsubscribe) {
785
+ this.#advisorYieldQueueUnsubscribe = this.#host.yieldQueue.register<AdvisorNote>("advisor", {
786
+ build: entries =>
787
+ entries.length === 0
788
+ ? null
789
+ : ({
790
+ role: "custom",
791
+ customType: "advisor",
792
+ display: true,
793
+ attribution: "agent",
794
+ timestamp: Date.now(),
795
+ content: formatAdvisorBatchContent(entries),
796
+ details: { notes: entries } satisfies AdvisorMessageDetails,
797
+ } satisfies CustomMessage),
798
+ skipIdleFlush: true,
799
+ });
800
+ }
801
+
802
+ return this.#advisors.length > 0;
803
+ }
804
+
805
+ /**
806
+ * Route one accepted advice note from `advisor` to the primary. Concern and
807
+ * blocker interrupt the running agent through the steering channel; once the
808
+ * loop has yielded, `triggerTurn` resumes it. After a terminal text answer with
809
+ * no queued work, a concern is preserved as a visible advisor card, while a
810
+ * blocker wakes the primary to acknowledge work it handed off incorrectly.
811
+ * After a deliberate user interrupt auto-resume is suppressed while idle/unwinding
812
+ * (the note becomes a preserved card re-entering on resume); a live-streaming turn is
813
+ * steered in directly. A plain nit always rides the non-interrupting YieldQueue
814
+ * aside. Suppression by the per-advisor emission guard drops the note silently —
815
+ * the model still saw `Recorded.`, so it isn't tempted to rephrase the same note
816
+ * past the dedupe.
817
+ */
818
+ #hasTerminalTextAnswerWithoutQueuedWork(): boolean {
819
+ if (this.#host.agent.hasQueuedMessages() || this.#host.hasPendingNextTurnMessages()) return false;
820
+ const messages = this.#host.agent.state.messages;
821
+ let tail = messages.length - 1;
822
+ while (tail >= 0 && isAdvisorCard(messages[tail])) tail--;
823
+ return isTerminalTextAssistantAnswer(messages[tail]);
824
+ }
825
+
826
+ #routeAdvice(advisor: ActiveAdvisor, note: string, severity?: AdvisorSeverity): void {
827
+ if (!advisor.emissionGuard.accept(note)) {
828
+ logger.debug("advisor advice suppressed by emission guard", { severity, advisor: advisor.name });
829
+ return;
830
+ }
831
+ // When newer primary turns already arrived while the advisor model was
832
+ // processing this batch, the advice was generated without seeing them.
833
+ // Append a lightweight staleness caveat so the primary can weigh recency.
834
+ const deliveredNote = annotateForStaleness(note, advisor.runtime.hasFreshBacklog);
835
+ // The implicit single ("default") advisor stamps no source name, so its
836
+ // agent-facing `<advisory>` bytes stay identical to the pre-multi-advisor path.
837
+ const source = advisor.slug ? advisor.name : undefined;
838
+ const interrupting = isInterruptingSeverity(severity);
839
+ const channel = resolveAdvisorDeliveryChannel({
840
+ severity,
841
+ autoResumeSuppressed: this.#advisorAutoResumeSuppressed,
842
+ preserveOnly: this.#preserveAdvisorAdvice,
843
+ // Key on the live agent-core loop, not session `isStreaming` (which also
844
+ // counts `#promptInFlightCount` during post-turn unwind). Only a running
845
+ // loop consumes a steer at its next boundary.
846
+ streaming: this.#host.agent.state.isStreaming,
847
+ aborting: this.#host.abortInProgress(),
848
+ terminalAnswerNoQueuedWork: this.#hasTerminalTextAnswerWithoutQueuedWork(),
849
+ interruptImmuneTurnActive: interrupting && this.#isAdvisorInterruptImmuneTurnActive(),
850
+ });
851
+ if (channel === "aside") {
852
+ this.#host.yieldQueue.enqueue("advisor", { note: deliveredNote, severity, advisor: source });
853
+ return;
854
+ }
855
+ const notes: AdvisorNote[] = [{ note: deliveredNote, severity, advisor: source }];
856
+ const content = formatAdvisorBatchContent(notes);
857
+ const details = { notes } satisfies AdvisorMessageDetails;
858
+ if (channel === "preserve") {
859
+ this.#host.preserveAdvisorCard({
860
+ role: "custom",
861
+ customType: "advisor",
862
+ content,
863
+ display: true,
864
+ attribution: "agent",
865
+ details,
866
+ timestamp: Date.now(),
867
+ });
868
+ return;
869
+ }
870
+ // A steered interrupting note only continues the run when the session can
871
+ // actually start (or is already running) a turn. Two idle cases cannot, so
872
+ // `sendCustomMessage({ triggerTurn: true })` would silently bury the card in
873
+ // `#pendingNextTurnMessages` until the next user prompt — strictly worse than
874
+ // the visible preserved card. Preserve instead:
875
+ // - Plan mode: only user-driven turns converge on ask/resolve.
876
+ // - ACP bridges with `deferAgentInitiatedTurns`: the client cannot show an
877
+ // agent-initiated turn as busy, so idle triggers are refused (#5628 review).
878
+ const cannotAutoTrigger =
879
+ !this.#host.agent.state.isStreaming &&
880
+ this.#host.clientBridge()?.deferAgentInitiatedTurns === true &&
881
+ !this.#host.allowAgentInitiatedTurns();
882
+ if (this.#host.planModeState()?.enabled || cannotAutoTrigger) {
883
+ this.#host.preserveAdvisorCard({
884
+ role: "custom",
885
+ customType: "advisor",
886
+ content,
887
+ display: true,
888
+ attribution: "agent",
889
+ details,
890
+ timestamp: Date.now(),
891
+ });
892
+ return;
893
+ }
894
+ // Arm the post-interrupt immune window only now that a turn is actually
895
+ // being steered/triggered. A merely preserved card never interrupts, so
896
+ // arming earlier would downgrade the next `advisor.immuneTurns` worth of
897
+ // real concerns/blockers to skip-idle-flush asides (#5628 review).
898
+ this.#recordAdvisorInterruptDelivered();
899
+ void this.#host
900
+ .sendCustomMessage(
901
+ { customType: "advisor", content, display: true, attribution: "agent", details },
902
+ { deliverAs: "steer", triggerTurn: true },
903
+ )
904
+ .catch(err => logger.debug("advisor delivery failed", { err: String(err) }));
905
+ }
906
+
907
+ /** Re-prime every advisor's transcript view (compaction/shake/rewind) without the
908
+ * session-level latch reset {@link #resetAdvisorSessionState} performs. */
909
+ #resetAllAdvisorRuntimes(): void {
910
+ for (const a of this.#advisors) a.runtime.reset();
911
+ }
912
+
913
+ #stopAdvisorRuntime(): void {
914
+ // Detach each recorder feed BEFORE aborting its advisor agent: dispose() aborts
915
+ // the loop, and an abort emits a final `message_end` we must not enqueue against
916
+ // a closing recorder (it would reopen and resurrect an already-released file).
917
+ const closes: Promise<void>[] = [];
918
+ for (const a of this.#advisors) {
919
+ a.agentUnsubscribe?.();
920
+ a.agentUnsubscribe = undefined;
921
+ a.runtime.dispose();
922
+ // Capture each close so dispose()/`/drop` can await the queued open+append+close —
923
+ // the last advisor turn would otherwise be lost on a fast process exit.
924
+ a.recorderClosed = a.recorder.close();
925
+ closes.push(a.recorderClosed);
926
+ }
927
+ this.#advisorRecorderClosed = Promise.all(closes).then(() => {});
928
+ this.#advisors = [];
929
+ this.#advisorYieldQueueUnsubscribe?.();
930
+ this.#advisorYieldQueueUnsubscribe = undefined;
931
+ }
932
+
933
+ /** Subscribe the advisor agent's finalized messages into the transcript recorder.
934
+ * Idempotent-by-replacement: callers detach the prior feed first. Kept separate
935
+ * so the re-prime path can mute the feed across an abort-driven reset. */
936
+ #attachAdvisorRecorderFeed(advisor: ActiveAdvisor): void {
937
+ advisor.agentUnsubscribe = advisor.agent.subscribe(event => {
938
+ if (event.type === "message_end") advisor.recorder.record(event.message);
939
+ });
940
+ }
941
+
942
+ /** Switch one advisor model while preserving its context and effort invariants. */
943
+ #setAdvisorModel(advisor: ActiveAdvisor, model: Model, requestedThinkingLevel: ThinkingLevel): ThinkingLevel {
944
+ const resolvedThinkingLevel = resolveThinkingLevelForModel(model, requestedThinkingLevel);
945
+ const nextThinkingLevel = resolvedThinkingLevel ?? ThinkingLevel.Inherit;
946
+ advisor.agent.setModel(model);
947
+ advisor.agent.setThinkingLevel(toReasoningEffort(nextThinkingLevel));
948
+ advisor.agent.setDisableReasoning(shouldDisableReasoning(nextThinkingLevel));
949
+ advisor.agent.appendOnlyContext?.invalidateForModelChange();
950
+ advisor.model = model;
951
+ advisor.thinkingLevel = nextThinkingLevel;
952
+ return nextThinkingLevel;
953
+ }
954
+
955
+ /** Restore an advisor's configured primary once its fallback cooldown expires. */
956
+ async #maybeRestoreAdvisorRetryFallbackPrimary(advisor: ActiveAdvisor): Promise<void> {
957
+ const fallback = advisor.retryFallback;
958
+ if (!fallback || getRetryFallbackRevertPolicy(this.#host.settings) !== "cooldown-expiry") return;
959
+
960
+ const originalSelector = parseRetryFallbackSelector(fallback.originalSelector, this.#host.modelRegistry);
961
+ if (!originalSelector) {
962
+ advisor.retryFallback = undefined;
963
+ advisor.retryFallbackPendingSuccess = false;
964
+ return;
965
+ }
966
+ const currentSelector = formatRetryFallbackSelector(advisor.agent.state.model, advisor.thinkingLevel);
967
+ if (currentSelector === originalSelector.raw) {
968
+ if (!this.#host.isRetryFallbackSelectorSuppressed(originalSelector)) {
969
+ advisor.retryFallback = undefined;
970
+ advisor.retryFallbackPendingSuccess = false;
971
+ }
972
+ return;
973
+ }
974
+ if (this.#host.isRetryFallbackSelectorSuppressed(originalSelector)) return;
975
+
976
+ const resolvedPrimary = resolveModelOverride(
977
+ [originalSelector.raw],
978
+ this.#host.modelRegistry,
979
+ this.#host.settings,
980
+ );
981
+ const primaryModel =
982
+ resolvedPrimary.model ?? this.#host.modelRegistry.find(originalSelector.provider, originalSelector.id);
983
+ if (!primaryModel) return;
984
+ const apiKey = await this.#host.modelRegistry.getApiKey(primaryModel, advisor.providerSessionId);
985
+ if (!apiKey) return;
986
+
987
+ const thinkingToApply =
988
+ advisor.thinkingLevel === fallback.lastAppliedThinkingLevel
989
+ ? fallback.originalThinkingLevel
990
+ : advisor.thinkingLevel;
991
+ this.#setAdvisorModel(advisor, primaryModel, thinkingToApply);
992
+ this.#host.settings.getStorage()?.recordModelUsage(formatModelStringWithRouting(primaryModel));
993
+ advisor.retryFallback = undefined;
994
+ advisor.retryFallbackPendingSuccess = false;
995
+ }
996
+
997
+ /**
998
+ * Apply the advisor's configured provider-failure fallback chain after
999
+ * same-provider credential rotation has no usable sibling.
1000
+ */
1001
+ async #recoverAdvisorTurn(
1002
+ advisor: ActiveAdvisor,
1003
+ error: unknown,
1004
+ failedMessages: readonly AgentMessage[],
1005
+ ): Promise<boolean> {
1006
+ if (error instanceof AdvisorOutputQuarantinedError) return false;
1007
+
1008
+ const failedMessage = failedMessages.findLast(
1009
+ (message): message is AssistantMessage => message.role === "assistant",
1010
+ );
1011
+ if (failedMessage?.stopReason !== "error") {
1012
+ // Stream setup can reject before any assistant turn is recorded (e.g.
1013
+ // an HTTP 429 thrown from prompt()); classify the raw error so a
1014
+ // structural usage limit still marks the exhausted credential.
1015
+ const message = error instanceof Error ? error.message : String(error);
1016
+ if (!AIError.isUsageLimit(error) && !isUsageLimitOutcome(extractHttpStatusFromError(error), message)) {
1017
+ return false;
1018
+ }
1019
+ const currentModel = advisor.agent.state.model;
1020
+ const outcome = await this.#host.modelRegistry.authStorage.markUsageLimitReached(
1021
+ currentModel.provider,
1022
+ advisor.providerSessionId,
1023
+ {
1024
+ retryAfterMs: extractRetryHint(undefined, message),
1025
+ baseUrl: currentModel.baseUrl,
1026
+ modelId: currentModel.id,
1027
+ },
1028
+ );
1029
+ return outcome.switched;
1030
+ }
1031
+ if (failedMessage.content.some(block => block.type === "toolCall")) return false;
1032
+
1033
+ const currentModel = advisor.agent.state.model;
1034
+ const message = failedMessage.errorMessage ?? (error instanceof Error ? error.message : String(error));
1035
+ const errorId = AIError.classifyMessage({
1036
+ api: currentModel.api,
1037
+ errorId: failedMessage.errorId,
1038
+ errorMessage: message,
1039
+ errorStatus: failedMessage.errorStatus,
1040
+ });
1041
+ if (AIError.is(errorId, AIError.Flag.Abort) || AIError.is(errorId, AIError.Flag.UserInterrupt)) return false;
1042
+ if (AIError.isContextOverflow(failedMessage, currentModel.contextWindow ?? 0)) return false;
1043
+
1044
+ const currentSelector = formatRetryFallbackSelector(currentModel, advisor.thinkingLevel);
1045
+
1046
+ const retryAfterMs = extractRetryHint(undefined, message);
1047
+ if (
1048
+ AIError.is(errorId, AIError.Flag.UsageLimit) ||
1049
+ isUsageLimitOutcome(extractHttpStatusFromError(error), message)
1050
+ ) {
1051
+ const outcome = await this.#host.modelRegistry.authStorage.markUsageLimitReached(
1052
+ currentModel.provider,
1053
+ advisor.providerSessionId,
1054
+ {
1055
+ retryAfterMs,
1056
+ baseUrl: currentModel.baseUrl,
1057
+ modelId: currentModel.id,
1058
+ },
1059
+ );
1060
+ if (outcome.switched) return true;
1061
+ }
1062
+
1063
+ const retrySettings = this.#host.settings.getGroup("retry");
1064
+ if (!retrySettings.enabled || !retrySettings.modelFallback) return false;
1065
+ const role = advisor.retryFallback?.role ?? this.#host.resolveRetryFallbackRole(currentSelector, currentModel);
1066
+ if (!role || this.#host.findRetryFallbackCandidates(role, currentSelector, currentModel).length === 0)
1067
+ return false;
1068
+
1069
+ this.#host.noteRetryFallbackCooldown(currentSelector, retryAfterMs, message);
1070
+ for (const selector of this.#host.findRetryFallbackCandidates(role, currentSelector, currentModel)) {
1071
+ if (this.#host.isRetryFallbackSelectorSuppressed(selector)) continue;
1072
+ const resolved = resolveModelOverride([selector.raw], this.#host.modelRegistry, this.#host.settings);
1073
+ const candidate = resolved.model ?? this.#host.modelRegistry.find(selector.provider, selector.id);
1074
+ if (!candidate || modelsAreEqual(candidate, currentModel)) continue;
1075
+ const apiKey = await this.#host.modelRegistry.getApiKey(candidate, advisor.providerSessionId);
1076
+ if (!apiKey) continue;
1077
+
1078
+ const originalThinkingLevel = advisor.thinkingLevel;
1079
+ const requestedThinkingLevel = selector.thinkingLevel ?? originalThinkingLevel;
1080
+ const nextThinkingLevel = this.#setAdvisorModel(advisor, candidate, requestedThinkingLevel);
1081
+ if (advisor.retryFallback) {
1082
+ advisor.retryFallback.lastAppliedThinkingLevel = nextThinkingLevel;
1083
+ } else {
1084
+ advisor.retryFallback = {
1085
+ role,
1086
+ originalSelector: currentSelector,
1087
+ originalThinkingLevel,
1088
+ lastAppliedThinkingLevel: nextThinkingLevel,
1089
+ };
1090
+ }
1091
+ advisor.retryFallbackPendingSuccess = true;
1092
+ this.#host.settings.getStorage()?.recordModelUsage(formatModelStringWithRouting(candidate));
1093
+ await this.#host.emitSessionEvent({
1094
+ type: "retry_fallback_applied",
1095
+ from: currentSelector,
1096
+ to: selector.raw,
1097
+ role,
1098
+ });
1099
+ return true;
1100
+ }
1101
+ return false;
1102
+ }
1103
+
1104
+ async #promoteAdvisorContextModel(advisor: ActiveAdvisor, currentModel: Model): Promise<boolean> {
1105
+ const promotionSettings = this.#host.settings.getGroup("contextPromotion");
1106
+ if (!promotionSettings.enabled) return false;
1107
+ const contextWindow = currentModel.contextWindow ?? 0;
1108
+ if (contextWindow <= 0) return false;
1109
+ const targetModel = await this.#host.resolveContextPromotionTarget(currentModel, contextWindow);
1110
+ if (!targetModel) return false;
1111
+
1112
+ // Preserve this advisor's own thinking level (a configured `model:...:high`
1113
+ // keeps its suffix across a promotion); only the model changes.
1114
+ const advisorThinkingLevel = advisor.thinkingLevel;
1115
+ try {
1116
+ this.#setAdvisorModel(advisor, targetModel, advisorThinkingLevel);
1117
+ logger.debug("Advisor context promotion switched model on overflow", {
1118
+ advisor: advisor.name,
1119
+ from: `${currentModel.provider}/${currentModel.id}`,
1120
+ to: `${targetModel.provider}/${targetModel.id}`,
1121
+ });
1122
+ return true;
1123
+ } catch (error) {
1124
+ logger.warn("Advisor context promotion failed", {
1125
+ advisor: advisor.name,
1126
+ from: `${currentModel.provider}/${currentModel.id}`,
1127
+ to: `${targetModel.provider}/${targetModel.id}`,
1128
+ error: String(error),
1129
+ });
1130
+ return false;
1131
+ }
1132
+ }
1133
+
1134
+ async #maintainAdvisorContext(advisor: ActiveAdvisor, incomingTokens: number): Promise<boolean> {
1135
+ await this.#maybeRestoreAdvisorRetryFallbackPrimary(advisor);
1136
+ const agent = advisor.agent;
1137
+
1138
+ const compactionSettings = this.#host.settings.getGroup("compaction");
1139
+ if (compactionSettings.strategy === "off") return false;
1140
+ if (!compactionSettings.enabled) return false;
1141
+
1142
+ const advisorModel = agent.state.model;
1143
+ const contextWindow = advisorModel.contextWindow ?? 0;
1144
+ if (contextWindow <= 0) return false;
1145
+
1146
+ const messages = agent.state.messages;
1147
+ const estimateOptions = { excludeEncryptedReasoning: true } as const;
1148
+ let storedConversationTokens = 0;
1149
+ for (const message of messages) {
1150
+ storedConversationTokens += estimateTokens(message, estimateOptions);
1151
+ }
1152
+ // Provider usage (including cache reads and generated output) is the
1153
+ // trustworthy anchor for accumulated context. Add only the trailing incoming
1154
+ // delta to that arm. Floor it by a full local estimate — fixed advisor system
1155
+ // prompt, tool schemas, stored messages, and incoming delta — so provider
1156
+ // under-reporting or payload transforms cannot suppress maintenance.
1157
+ const providerContextTokens = this.#estimateAdvisorContextTokens(messages) + incomingTokens;
1158
+ const localContextTokens =
1159
+ countTokens(agent.state.systemPrompt) +
1160
+ estimateToolSchemaTokens(agent.state.tools) +
1161
+ storedConversationTokens +
1162
+ incomingTokens;
1163
+ const contextTokens = compactionContextTokens(providerContextTokens, localContextTokens);
1164
+
1165
+ if (!shouldCompact(contextTokens, contextWindow, compactionSettings)) {
1166
+ return false;
1167
+ }
1168
+
1169
+ // 1. Try promotion first
1170
+ if (await this.#promoteAdvisorContextModel(advisor, advisorModel)) {
1171
+ // Promotion succeeded, check if new model has enough space
1172
+ const newModel = agent.state.model;
1173
+ const newWindow = newModel.contextWindow ?? 0;
1174
+ if (newWindow > 0) {
1175
+ const stillNeedsCompaction = shouldCompact(contextTokens, newWindow, compactionSettings);
1176
+ if (!stillNeedsCompaction) return false;
1177
+ }
1178
+ }
1179
+
1180
+ // 2. Run compaction on advisor messages
1181
+ const pathEntries: SessionEntry[] = messages.map((message, i) => {
1182
+ const id = `msg-${i}`;
1183
+ const parentId = i > 0 ? `msg-${i - 1}` : null;
1184
+ const timestamp = String(message.timestamp || Date.now());
1185
+
1186
+ if (message.role === "compactionSummary") {
1187
+ const advisorSummary = message as AdvisorCompactionSummaryMessage;
1188
+ return {
1189
+ type: "compaction",
1190
+ id,
1191
+ parentId,
1192
+ timestamp,
1193
+ summary: message.summary,
1194
+ shortSummary: message.shortSummary,
1195
+ firstKeptEntryId: advisorSummary.firstKeptEntryId || `msg-${i + 1}`,
1196
+ tokensBefore: message.tokensBefore,
1197
+ } satisfies CompactionEntry;
1198
+ }
1199
+
1200
+ return {
1201
+ type: "message",
1202
+ id,
1203
+ parentId,
1204
+ timestamp,
1205
+ message,
1206
+ } satisfies SessionMessageEntry;
1207
+ });
1208
+
1209
+ const availableModels = this.#host.modelRegistry.getAvailable();
1210
+ const candidates = this.#host.resolveCompactionModelCandidates(advisorModel, availableModels);
1211
+ if (candidates.length === 0) {
1212
+ // No compaction candidates, fallback to re-prime
1213
+ return true;
1214
+ }
1215
+ const advisorProviderSessionId = getOrCreateAdvisorProviderSessionId(
1216
+ this.#advisorProviderSessionIds,
1217
+ this.#host.sessionId(),
1218
+ advisor.slug,
1219
+ );
1220
+ const preparation = prepareCompaction(pathEntries, compactionSettings, advisorModel);
1221
+ if (!preparation) {
1222
+ // Cannot prepare compaction, fallback to re-prime
1223
+ return true;
1224
+ }
1225
+
1226
+ const advisorCompactionThinkingLevel: ThinkingLevel | undefined = agent.state.disableReasoning
1227
+ ? ThinkingLevel.Off
1228
+ : agent.state.thinkingLevel;
1229
+
1230
+ // Advisor state is in-memory-only, so snapcompact's frame archive has no
1231
+ // stable SessionEntry preserveData slot to carry across future advisor
1232
+ // maintenance runs. Use an LLM summary even when the primary session is
1233
+ // configured for snapcompact.
1234
+
1235
+ let compactResult: CompactionResult | undefined;
1236
+ let lastError: unknown;
1237
+ // Instrument the advisor's overflow-compaction one-shot like the primary
1238
+ // compaction path so the advisor model's maintenance call also emits spans.
1239
+ const telemetry = resolveTelemetry(agent.telemetry, advisorProviderSessionId);
1240
+
1241
+ const codexCompaction = this.#host.createCodexCompactionContext({
1242
+ trigger: "auto",
1243
+ reason: "context_limit",
1244
+ phase: "pre_turn",
1245
+ });
1246
+
1247
+ for (const candidate of candidates) {
1248
+ const apiKey = await this.#host.modelRegistry.getApiKey(candidate, advisorProviderSessionId);
1249
+ if (!apiKey) continue;
1250
+
1251
+ try {
1252
+ compactResult = await compact(
1253
+ preparation,
1254
+ candidate,
1255
+ this.#host.modelRegistry.resolver(candidate, advisorProviderSessionId),
1256
+ undefined,
1257
+ undefined,
1258
+ {
1259
+ thinkingLevel: advisorCompactionThinkingLevel,
1260
+ convertToLlm: messages => this.#host.convertToLlmForSideRequest(messages),
1261
+ telemetry,
1262
+ tools: agent.state.tools,
1263
+ sessionId: advisorProviderSessionId,
1264
+ promptCacheKey: advisorProviderSessionId,
1265
+ providerSessionState: this.#host.providerSessionState,
1266
+ codexCompaction,
1267
+ },
1268
+ );
1269
+ break;
1270
+ } catch (error) {
1271
+ lastError = error;
1272
+ }
1273
+ }
1274
+
1275
+ if (!compactResult) {
1276
+ logger.warn("Advisor compaction failed, falling back to re-prime", { error: String(lastError) });
1277
+ return true;
1278
+ }
1279
+
1280
+ const summary = compactResult.summary;
1281
+ const shortSummary = compactResult.shortSummary;
1282
+ const firstKeptEntryId = compactResult.firstKeptEntryId;
1283
+ const tokensBefore = compactResult.tokensBefore;
1284
+
1285
+ // The retained messages still carry provider usage from before this
1286
+ // compaction. Record their exact array boundary on the in-memory summary so
1287
+ // only assistants appended afterward can become the next usage anchor.
1288
+ const advisorUsageAnchorStartIndex = preparation.recentMessages.length + 1;
1289
+ const summaryMessage = {
1290
+ ...createCompactionSummaryMessage(summary, tokensBefore, new Date().toISOString(), shortSummary),
1291
+ firstKeptEntryId,
1292
+ advisorUsageAnchorStartIndex,
1293
+ } satisfies AdvisorCompactionSummaryMessage;
1294
+
1295
+ agent.replaceMessages([summaryMessage, ...preparation.recentMessages]);
1296
+ return false;
1297
+ }
1298
+ /**
1299
+ * Prevent advisor notes from starting hidden primary turns while a headless
1300
+ * caller prints and drains the final primary response.
1301
+ */
1302
+ prepareForHeadlessAdvisorDrain(): void {
1303
+ this.#preserveAdvisorAdvice = true;
1304
+ }
1305
+
1306
+ async #waitForPendingAdvisorCardEvents(timeoutMs: number): Promise<boolean> {
1307
+ const deadline = Date.now() + Math.max(0, timeoutMs);
1308
+ while (this.#pendingAdvisorCardEvents.size > 0) {
1309
+ const remainingMs = deadline - Date.now();
1310
+ if (remainingMs <= 0) return false;
1311
+ const settled = Promise.allSettled([...this.#pendingAdvisorCardEvents]).then(() => true as const);
1312
+ const { promise: timedOut, resolve } = Promise.withResolvers<false>();
1313
+ const timer = setTimeout(() => resolve(false), remainingMs);
1314
+ try {
1315
+ if (!(await Promise.race([settled, timedOut]))) return false;
1316
+ } finally {
1317
+ clearTimeout(timer);
1318
+ }
1319
+ }
1320
+ return true;
1321
+ }
1322
+
1323
+ /**
1324
+ * Wait for active advisor reviews and their emitted card events before a
1325
+ * headless caller disposes the session. Returns `false` and logs work disposal
1326
+ * will abandon when the shared deadline expires or an advisor fails.
1327
+ */
1328
+ async waitForAdvisorCatchup(timeoutMs: number): Promise<boolean> {
1329
+ const deadline = Date.now() + timeoutMs;
1330
+ const results = await Promise.all(this.#advisors.map(advisor => advisor.runtime.waitForCatchup(timeoutMs, 1)));
1331
+ const cardEventsCaughtUp = await this.#waitForPendingAdvisorCardEvents(Math.max(0, deadline - Date.now()));
1332
+ const abandoned = this.#advisors.filter(
1333
+ (advisor, index) => results[index] === false && advisor.runtime.backlog > 0,
1334
+ );
1335
+ if (abandoned.length > 0 || !cardEventsCaughtUp) {
1336
+ logger.warn("advisor shutdown drain incomplete; disposal will abandon reviews or cards", {
1337
+ timeoutMs,
1338
+ advisors: abandoned.map(advisor => ({ name: advisor.name, backlog: advisor.runtime.backlog })),
1339
+ pendingAdvisorCards: this.#pendingAdvisorCardEvents.size,
1340
+ });
1341
+ return false;
1342
+ }
1343
+ return true;
1344
+ }
1345
+ /**
1346
+ * Enable or disable the advisor for this session. The setting is overridden for the session,
1347
+ * and the runtime is started or stopped to match.
1348
+ *
1349
+ * @returns true when the advisor is actively running after the call.
1350
+ */
1351
+ setAdvisorEnabled(enabled: boolean): boolean {
1352
+ this.#advisorEnabled = enabled;
1353
+ if (enabled) {
1354
+ if (this.#advisors.length > 0 && !this.#advisorRuntimeMatchesCurrentConfig()) this.#stopAdvisorRuntime();
1355
+ return this.#buildAdvisorRuntime(true);
1356
+ }
1357
+ this.#stopAdvisorRuntime();
1358
+ return false;
1359
+ }
1360
+
1361
+ /**
1362
+ * Toggle the advisor setting and start/stop the runtime accordingly.
1363
+ *
1364
+ * @returns true when the advisor is actively running after the call.
1365
+ */
1366
+ toggleAdvisorEnabled(): boolean {
1367
+ return this.setAdvisorEnabled(!this.#advisorEnabled);
1368
+ }
1369
+
1370
+ /**
1371
+ * Replace the live advisor roster from an edited `WATCHDOG.yml` (the `/advisor
1372
+ * configure` save path). Swaps the configs + shared baseline, then rebuilds the
1373
+ * runtimes in place so the change applies without a restart. When the advisor is
1374
+ * disabled the new configs are simply stored for the next enable.
1375
+ *
1376
+ * @returns the number of advisors active after the rebuild.
1377
+ */
1378
+ applyAdvisorConfigs(advisors: AdvisorConfig[], sharedInstructions: string | undefined): number {
1379
+ this.#advisorConfigs = advisors;
1380
+ this.#advisorSharedInstructions = sharedInstructions;
1381
+ if (!this.#advisorEnabled) return 0;
1382
+ this.#stopAdvisorRuntime();
1383
+ this.#buildAdvisorRuntime(true);
1384
+ return this.#advisors.length;
1385
+ }
1386
+
1387
+ /**
1388
+ * Whether the advisor setting is enabled for this session.
1389
+ */
1390
+ isAdvisorEnabled(): boolean {
1391
+ return this.#advisorEnabled;
1392
+ }
1393
+
1394
+ /**
1395
+ * Whether a live advisor agent is attached to this session. True only when
1396
+ * `advisor.enabled` is set AND a model resolved for the `advisor` role AND
1397
+ * the advisor applies to this agent kind — i.e. the actual runtime exists,
1398
+ * not merely the setting. Drives the status-line badge and `/dump advisor`.
1399
+ */
1400
+ isAdvisorActive(): boolean {
1401
+ return this.#advisors.length > 0;
1402
+ }
1403
+
1404
+ /**
1405
+ * The names of the tools available to advisors this session (the pool a
1406
+ * `/advisor configure` editor lists). The advisor is a full agent, so this is the
1407
+ * full built tool set; a tool whose optional factory returns null (e.g. lsp with
1408
+ * no servers) is absent.
1409
+ */
1410
+ getAdvisorAvailableToolNames(): string[] {
1411
+ return (this.#advisorTools ?? []).map(tool => tool.name);
1412
+ }
1413
+
1414
+ /**
1415
+ * The live advisor `Agent`, or `undefined` when no advisor runtime is
1416
+ * attached. Surfaced for diagnostics (`/dump advisor` already serializes
1417
+ * its transcript via {@link formatAdvisorHistoryAsText}) and so callers can
1418
+ * verify the advisor inherits the session's provider-shaping options
1419
+ * (`streamFn`, `promptCacheKey`, `providerSessionState`, ...).
1420
+ */
1421
+ getAdvisorAgent(): Agent | undefined {
1422
+ return this.#advisors[0]?.agent;
1423
+ }
1424
+
1425
+ /**
1426
+ * Lightweight advisor status for the status line: returns just the configured
1427
+ * flag and per-advisor name/status without computing token/cost breakdowns.
1428
+ * Avoids re-tokenizing the advisor transcript on every render frame.
1429
+ */
1430
+ getAdvisorStatusOverview(): { configured: boolean; advisors: { name: string; status: AdvisorRuntimeStatus }[] } {
1431
+ // Override stale map entries with live runtime status: failureNotified/quotaExhausted
1432
+ // clear on reset() but #advisorStatuses lags until the next build.
1433
+ const liveStatusBySlug = new Map<string, AdvisorRuntimeStatus>();
1434
+ for (const a of this.#advisors) {
1435
+ liveStatusBySlug.set(
1436
+ a.slug,
1437
+ a.runtime.quotaExhausted ? "quota_exhausted" : a.runtime.failureNotified ? "error" : "running",
1438
+ );
1439
+ }
1440
+ const advisors = [...this.#advisorStatuses.entries()].map(([slug, { name, status }]) => ({
1441
+ name,
1442
+ status: liveStatusBySlug.get(slug) ?? status,
1443
+ }));
1444
+ return { configured: this.#advisorEnabled, advisors };
1445
+ }
1446
+ /**
1447
+ * Return structured advisor stats for the status command and TUI panel.
1448
+ */
1449
+ getAdvisorStats(): AdvisorStats {
1450
+ const configured = this.#advisorEnabled;
1451
+ const liveAdvisors = this.#advisors.map(a => this.#computeAdvisorStat(a));
1452
+ // Build the complete roster from #advisorStatuses, which already has the
1453
+ // correct de-duped slugs as keys. Live advisors (from #advisors) carry full
1454
+ // token/cost data; disabled/no-model/quota-exhausted advisors appear as
1455
+ // skeleton entries with just name + status so the status line renders a dot.
1456
+ const liveStatBySlug = new Map(this.#advisors.map((a, i) => [a.slug, liveAdvisors[i]]));
1457
+ const roster: PerAdvisorStat[] = [];
1458
+ for (const [slug, entry] of this.#advisorStatuses) {
1459
+ const live = liveStatBySlug.get(slug);
1460
+ if (live) {
1461
+ roster.push(live);
1462
+ } else {
1463
+ roster.push({
1464
+ name: entry.name,
1465
+ status: entry.status,
1466
+ contextWindow: 0,
1467
+ contextTokens: 0,
1468
+ tokens: { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
1469
+ cost: 0,
1470
+ messages: { user: 0, assistant: 0, total: 0 },
1471
+ });
1472
+ }
1473
+ }
1474
+ const active = liveAdvisors.length > 0;
1475
+ if (liveAdvisors.length === 0) {
1476
+ return {
1477
+ configured,
1478
+ active,
1479
+ contextWindow: 0,
1480
+ contextTokens: 0,
1481
+ tokens: { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
1482
+ cost: 0,
1483
+ messages: { user: 0, assistant: 0, total: 0 },
1484
+ advisors: roster,
1485
+ };
1486
+ }
1487
+ const tokens = { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0, total: 0 };
1488
+ const messages = { user: 0, assistant: 0, total: 0 };
1489
+ let cost = 0;
1490
+ let contextTokens = 0;
1491
+ for (const a of liveAdvisors) {
1492
+ tokens.input += a.tokens.input;
1493
+ tokens.output += a.tokens.output;
1494
+ tokens.reasoning += a.tokens.reasoning;
1495
+ tokens.cacheRead += a.tokens.cacheRead;
1496
+ tokens.cacheWrite += a.tokens.cacheWrite;
1497
+ tokens.total += a.tokens.total;
1498
+ messages.user += a.messages.user;
1499
+ messages.assistant += a.messages.assistant;
1500
+ messages.total += a.messages.total;
1501
+ cost += a.cost;
1502
+ contextTokens += a.contextTokens;
1503
+ }
1504
+ // Single-advisor displays read the top-level model/window directly; surface the
1505
+ // first advisor's so the legacy status line stays byte-identical.
1506
+ return {
1507
+ configured,
1508
+ active,
1509
+ model: liveAdvisors[0].model,
1510
+ contextWindow: liveAdvisors[0].contextWindow,
1511
+ contextTokens,
1512
+ tokens,
1513
+ cost,
1514
+ messages,
1515
+ advisors: roster,
1516
+ };
1517
+ }
1518
+
1519
+ /** Compute one advisor's stats slice (tokens, cost, context, message counts). */
1520
+ #computeAdvisorStat(advisor: ActiveAdvisor): PerAdvisorStat {
1521
+ const model = advisor.agent.state.model;
1522
+ const messages = advisor.agent.state.messages;
1523
+ const contextTokens = this.#estimateAdvisorContextTokens(messages);
1524
+ let input = 0;
1525
+ let output = 0;
1526
+ let reasoning = 0;
1527
+ let cacheRead = 0;
1528
+ let cacheWrite = 0;
1529
+ let totalTokens = 0;
1530
+ let cost = 0;
1531
+ let user = 0;
1532
+ let assistant = 0;
1533
+ for (const message of messages) {
1534
+ if (message.role === "user") user++;
1535
+ if (message.role === "assistant") {
1536
+ assistant++;
1537
+ const assistantMsg = message as AssistantMessage;
1538
+ input += assistantMsg.usage.input;
1539
+ output += assistantMsg.usage.output;
1540
+ reasoning += assistantMsg.usage.reasoningTokens ?? 0;
1541
+ cacheRead += assistantMsg.usage.cacheRead;
1542
+ cacheWrite += assistantMsg.usage.cacheWrite;
1543
+ totalTokens += assistantMsg.usage.totalTokens;
1544
+ cost += assistantMsg.usage.cost.total;
1545
+ }
1546
+ }
1547
+ return {
1548
+ name: advisor.name,
1549
+ status: advisor.runtime.quotaExhausted
1550
+ ? "quota_exhausted"
1551
+ : advisor.runtime.failureNotified
1552
+ ? "error"
1553
+ : "running",
1554
+ model,
1555
+ contextWindow: model.contextWindow ?? 0,
1556
+ contextTokens,
1557
+ tokens: { input, output, reasoning, cacheRead, cacheWrite, total: totalTokens },
1558
+ cost,
1559
+ messages: { user, assistant, total: messages.length },
1560
+ sessionId: advisor.agent.sessionId,
1561
+ };
1562
+ }
1563
+
1564
+ /**
1565
+ * Format a concise advisor status line for ACP/text output.
1566
+ */
1567
+ formatAdvisorStatus(): string {
1568
+ const stats = this.getAdvisorStats();
1569
+ if (!stats.active && stats.advisors.length === 0) {
1570
+ return stats.configured
1571
+ ? "Advisor setting is enabled, but no model is assigned to the 'advisor' role."
1572
+ : "Advisor is disabled.";
1573
+ }
1574
+ if (stats.advisors.length <= 1) {
1575
+ const s = stats.advisors[0];
1576
+ if (s && s.status === "no_model") {
1577
+ return stats.configured
1578
+ ? "Advisor setting is enabled, but no model is assigned to the 'advisor' role."
1579
+ : "Advisor is disabled.";
1580
+ }
1581
+ const contextLine =
1582
+ s.contextWindow > 0
1583
+ ? `Context: ${s.contextTokens.toLocaleString()} / ${s.contextWindow.toLocaleString()} tokens (${Math.round((s.contextTokens / s.contextWindow) * 100)}%)`
1584
+ : `Context: ${s.contextTokens.toLocaleString()} tokens`;
1585
+ const spendParts = [`${s.tokens.input.toLocaleString()} input`, `${s.tokens.output.toLocaleString()} output`];
1586
+ if (s.tokens.cacheRead > 0) spendParts.push(`${s.tokens.cacheRead.toLocaleString()} cache read`);
1587
+ if (s.tokens.cacheWrite > 0) spendParts.push(`${s.tokens.cacheWrite.toLocaleString()} cache write`);
1588
+ const spendLine = `Spend: ${spendParts.join(", ")}, $${s.cost.toFixed(4)}`;
1589
+ if (!s.model || s.status !== "running") return `Advisor "${s.name}" is ${s.status.replace("_", " ")}.`;
1590
+ return `Advisor is enabled (${s.model.provider}/${s.model.id}). ${contextLine}. ${spendLine}.`;
1591
+ }
1592
+ const lines = [`Advisors enabled (${stats.advisors.length}):`];
1593
+ for (const s of stats.advisors) {
1594
+ const ctx =
1595
+ s.contextWindow > 0
1596
+ ? `${s.contextTokens.toLocaleString()} / ${s.contextWindow.toLocaleString()} (${Math.round((s.contextTokens / s.contextWindow) * 100)}%)`
1597
+ : `${s.contextTokens.toLocaleString()}`;
1598
+ lines.push(
1599
+ ` • ${s.name}${s.model && s.status === "running" ? ` (${s.model.provider}/${s.model.id})` : ` [${s.status}]`} — context ${ctx} tokens, $${s.cost.toFixed(4)}`,
1600
+ );
1601
+ }
1602
+ lines.push(
1603
+ `Totals: ${stats.tokens.input.toLocaleString()} input, ${stats.tokens.output.toLocaleString()} output, $${stats.cost.toFixed(4)}.`,
1604
+ );
1605
+ return lines.join("\n");
1606
+ }
1607
+
1608
+ /**
1609
+ * Estimate the advisor's current context tokens. A successful provider usage
1610
+ * after the latest advisor compaction is ground truth for the prompt plus its
1611
+ * generated output; only messages after that anchor are estimated. Usage from
1612
+ * retained pre-compaction messages is stale and must not immediately retrigger
1613
+ * maintenance on the newly compacted context.
1614
+ */
1615
+ #estimateAdvisorContextTokens(messages: AgentMessage[]): number {
1616
+ let usageAnchorStartIndex = 0;
1617
+ for (let i = messages.length - 1; i >= 0; i--) {
1618
+ const message = messages[i];
1619
+ if (message.role !== "compactionSummary") continue;
1620
+ const advisorSummary = message as AdvisorCompactionSummaryMessage;
1621
+ // Advisor summaries created before this runtime-only boundary existed have
1622
+ // no trustworthy way to distinguish retained from newly appended messages.
1623
+ // Conservatively ignore every current assistant until the next compaction.
1624
+ usageAnchorStartIndex = advisorSummary.advisorUsageAnchorStartIndex ?? messages.length;
1625
+ break;
1626
+ }
1627
+
1628
+ let lastUsageIndex: number | undefined;
1629
+ let lastUsage: AssistantMessage["usage"] | undefined;
1630
+ for (let i = messages.length - 1; i >= usageAnchorStartIndex; i--) {
1631
+ const message = messages[i];
1632
+ if (message.role !== "assistant") continue;
1633
+ const assistant = message as AssistantMessage;
1634
+ if (assistant.stopReason !== "aborted" && assistant.stopReason !== "error" && assistant.usage) {
1635
+ lastUsage = assistant.usage;
1636
+ lastUsageIndex = i;
1637
+ break;
1638
+ }
1639
+ }
1640
+
1641
+ const estimateOptions = { excludeEncryptedReasoning: true } as const;
1642
+ if (!lastUsage || lastUsageIndex === undefined) {
1643
+ let estimated = 0;
1644
+ for (const message of messages) {
1645
+ estimated += estimateTokens(message, estimateOptions);
1646
+ }
1647
+ return estimated;
1648
+ }
1649
+ let trailingTokens = 0;
1650
+ for (let i = lastUsageIndex + 1; i < messages.length; i++) {
1651
+ trailingTokens += estimateTokens(messages[i], estimateOptions);
1652
+ }
1653
+ return calculateContextTokens(lastUsage) + trailingTokens;
1654
+ }
1655
+
1656
+ /**
1657
+ * Format the advisor agent's own transcript (its system prompt, config,
1658
+ * tools, and the markdown deltas it received plus its thinking/advise/read
1659
+ * calls) as plain text — the advisor-side equivalent of
1660
+ * {@link formatSessionAsText}. Returns null when no advisor is active.
1661
+ */
1662
+ formatAdvisorHistoryAsText(options?: { compact?: boolean }): string | null {
1663
+ if (this.#advisors.length === 0) return null;
1664
+ const dump = (a: ActiveAdvisor): string =>
1665
+ options?.compact
1666
+ ? formatSessionHistoryMarkdown(a.agent.state.messages)
1667
+ : formatSessionDumpText({
1668
+ messages: a.agent.state.messages,
1669
+ systemPrompt: a.agent.state.systemPrompt,
1670
+ model: a.agent.state.model,
1671
+ thinkingLevel: a.agent.state.thinkingLevel,
1672
+ tools: a.agent.state.tools,
1673
+ });
1674
+ if (this.#advisors.length === 1) return dump(this.#advisors[0]);
1675
+ return this.#advisors
1676
+ .map(a => `### Advisor: ${a.name} (${a.agent.state.model.provider}/${a.agent.state.model.id})\n\n${dump(a)}`)
1677
+ .join("\n\n");
1678
+ }
1679
+ }