@nghyane/arcane 0.1.13 → 0.1.15

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 (303) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/package.json +21 -70
  3. package/scripts/format-prompts.ts +1 -3
  4. package/src/cli/args.ts +2 -7
  5. package/src/cli/config-cli.ts +1 -1
  6. package/src/cli/plugin-cli.ts +1 -1
  7. package/src/cli/setup-cli.ts +1 -1
  8. package/src/cli/update-cli.ts +1 -1
  9. package/src/cli/web-search-cli.ts +1 -1
  10. package/src/cli.ts +0 -1
  11. package/src/commands/config.ts +1 -1
  12. package/src/commands/grep.ts +1 -1
  13. package/src/commands/jupyter.ts +1 -1
  14. package/src/commands/plugin.ts +1 -1
  15. package/src/commands/setup.ts +1 -1
  16. package/src/commands/shell.ts +1 -1
  17. package/src/commands/ssh.ts +1 -1
  18. package/src/commands/stats.ts +1 -1
  19. package/src/commands/update.ts +1 -1
  20. package/src/config/model-registry.ts +3 -4
  21. package/src/config/model-resolver.ts +36 -9
  22. package/src/config/prompt-templates.ts +1 -9
  23. package/src/config/settings-schema.ts +32 -88
  24. package/src/config/settings.ts +3 -4
  25. package/src/debug/index.ts +1 -1
  26. package/src/debug/log-formatting.ts +1 -1
  27. package/src/debug/log-viewer.ts +2 -2
  28. package/src/discovery/helpers.ts +13 -3
  29. package/src/exa/index.ts +1 -35
  30. package/src/exa/render.ts +30 -190
  31. package/src/export/html/index.ts +1 -1
  32. package/src/extensibility/custom-tools/loader.ts +1 -1
  33. package/src/extensibility/custom-tools/types.ts +5 -1
  34. package/src/extensibility/custom-tools/wrapper.ts +1 -1
  35. package/src/extensibility/extensions/runner.ts +1 -1
  36. package/src/extensibility/extensions/types.ts +1 -1
  37. package/src/extensibility/extensions/wrapper.ts +7 -15
  38. package/src/extensibility/hooks/runner.ts +1 -1
  39. package/src/extensibility/hooks/types.ts +1 -1
  40. package/src/extensibility/plugins/doctor.ts +1 -1
  41. package/src/index.ts +13 -13
  42. package/src/lsp/index.ts +77 -24
  43. package/src/lsp/render.ts +34 -583
  44. package/src/lsp/types.ts +3 -3
  45. package/src/lsp/utils.ts +1 -1
  46. package/src/main.ts +1 -1
  47. package/src/mcp/tool-bridge.ts +1 -24
  48. package/src/modes/components/assistant-message.ts +7 -7
  49. package/src/modes/components/bash-execution.ts +50 -112
  50. package/src/modes/components/bordered-loader.ts +1 -1
  51. package/src/modes/components/branch-summary-message.ts +16 -10
  52. package/src/modes/components/compaction-summary-message.ts +20 -12
  53. package/src/modes/components/context-group.ts +106 -0
  54. package/src/modes/components/custom-message.ts +4 -5
  55. package/src/modes/components/diff.ts +2 -2
  56. package/src/modes/components/dynamic-border.ts +1 -1
  57. package/src/modes/components/extensions/extension-dashboard.ts +1 -1
  58. package/src/modes/components/extensions/extension-list.ts +1 -1
  59. package/src/modes/components/extensions/inspector-panel.ts +1 -1
  60. package/src/modes/components/footer.ts +2 -2
  61. package/src/modes/components/history-search.ts +1 -1
  62. package/src/modes/components/hook-editor.ts +1 -1
  63. package/src/modes/components/hook-input.ts +1 -1
  64. package/src/modes/components/hook-message.ts +4 -5
  65. package/src/modes/components/hook-selector.ts +1 -1
  66. package/src/modes/components/index.ts +0 -2
  67. package/src/modes/components/keybinding-hints.ts +1 -1
  68. package/src/modes/components/login-dialog.ts +1 -1
  69. package/src/modes/components/mcp-add-wizard.ts +1 -1
  70. package/src/modes/components/model-selector.ts +1 -1
  71. package/src/modes/components/oauth-selector.ts +1 -1
  72. package/src/modes/components/plugin-settings.ts +1 -1
  73. package/src/modes/components/python-execution.ts +51 -91
  74. package/src/modes/components/queue-mode-selector.ts +1 -1
  75. package/src/modes/components/session-selector.ts +1 -1
  76. package/src/modes/components/settings-defs.ts +5 -10
  77. package/src/modes/components/settings-selector.ts +1 -1
  78. package/src/modes/components/show-images-selector.ts +1 -1
  79. package/src/modes/components/skill-message.ts +4 -4
  80. package/src/modes/components/status-line/segments.ts +2 -2
  81. package/src/modes/components/status-line/separators.ts +1 -1
  82. package/src/modes/components/status-line-segment-editor.ts +1 -1
  83. package/src/modes/components/status-line.ts +1 -1
  84. package/src/modes/components/theme-selector.ts +1 -1
  85. package/src/modes/components/thinking-selector.ts +1 -1
  86. package/src/modes/components/todo-display.ts +2 -4
  87. package/src/modes/components/todo-reminder.ts +4 -4
  88. package/src/modes/components/tool-execution.ts +118 -440
  89. package/src/modes/components/tool-image-display.ts +107 -0
  90. package/src/modes/components/tree-selector.ts +2 -2
  91. package/src/modes/components/ttsr-notification.ts +4 -17
  92. package/src/modes/components/user-message-selector.ts +1 -1
  93. package/src/modes/components/user-message.ts +9 -10
  94. package/src/modes/components/welcome.ts +1 -1
  95. package/src/modes/controllers/command-controller.ts +1 -1
  96. package/src/modes/controllers/event-controller.ts +58 -187
  97. package/src/modes/controllers/extension-ui-controller.ts +1 -1
  98. package/src/modes/controllers/input-controller.ts +3 -1
  99. package/src/modes/controllers/mcp-command-controller.ts +1 -1
  100. package/src/modes/controllers/selector-controller.ts +3 -26
  101. package/src/modes/controllers/ssh-command-controller.ts +1 -1
  102. package/src/modes/interactive-mode.ts +3 -7
  103. package/src/modes/print-mode.ts +5 -5
  104. package/src/modes/rpc/rpc-mode.ts +1 -1
  105. package/src/modes/types.ts +1 -2
  106. package/src/modes/utils/ui-helpers.ts +34 -32
  107. package/src/patch/edit-tool.ts +742 -0
  108. package/src/patch/index.ts +32 -898
  109. package/src/patch/schemas.ts +208 -0
  110. package/src/patch/shared.ts +83 -151
  111. package/src/prompts/agents/explore.md +22 -37
  112. package/src/prompts/agents/init.md +1 -1
  113. package/src/prompts/agents/librarian.md +29 -20
  114. package/src/prompts/agents/oracle.md +9 -2
  115. package/src/prompts/agents/reviewer.md +14 -48
  116. package/src/prompts/agents/task.md +16 -8
  117. package/src/prompts/compaction/branch-summary.md +4 -1
  118. package/src/prompts/compaction/compaction-summary.md +4 -1
  119. package/src/prompts/system/subagent-system-prompt.md +1 -1
  120. package/src/prompts/system/system-prompt.md +162 -178
  121. package/src/prompts/system/verification-reminder.md +6 -0
  122. package/src/sdk.ts +0 -9
  123. package/src/session/agent-session.ts +244 -1459
  124. package/src/session/model-controller.ts +406 -0
  125. package/src/session/retry-utils.ts +71 -0
  126. package/src/session/session-manager.ts +22 -186
  127. package/src/session/session-types.ts +312 -0
  128. package/src/session/stats.ts +387 -0
  129. package/src/session/streaming-edit.ts +258 -0
  130. package/src/session/ttsr.ts +213 -0
  131. package/src/slash-commands/builtin-registry.ts +0 -8
  132. package/src/stt/recorder.ts +2 -2
  133. package/src/system-prompt.ts +1 -14
  134. package/src/task/agents.ts +7 -33
  135. package/src/task/executor.ts +50 -438
  136. package/src/task/index.ts +104 -71
  137. package/src/task/progress-tracker.ts +390 -0
  138. package/src/task/render.ts +371 -187
  139. package/src/task/subprocess-tool-registry.ts +1 -1
  140. package/src/task/types.ts +14 -47
  141. package/src/tools/ask.ts +31 -42
  142. package/src/tools/bash-interactive.ts +2 -2
  143. package/src/tools/bash-interceptor.ts +2 -2
  144. package/src/tools/bash-normalize.ts +1 -1
  145. package/src/tools/bash-skill-urls.ts +2 -2
  146. package/src/tools/bash.ts +87 -136
  147. package/src/tools/browser.ts +54 -84
  148. package/src/tools/create-tools.ts +186 -0
  149. package/src/tools/default-renderer.ts +104 -0
  150. package/src/tools/explore.ts +11 -10
  151. package/src/tools/fetch.ts +24 -114
  152. package/src/tools/find.ts +48 -132
  153. package/src/tools/gemini-image.ts +5 -15
  154. package/src/tools/github.ts +450 -0
  155. package/src/tools/grep.ts +43 -179
  156. package/src/tools/index.ts +35 -198
  157. package/src/tools/json-tree.ts +3 -3
  158. package/src/tools/librarian.ts +18 -18
  159. package/src/tools/list-limit.ts +2 -2
  160. package/src/tools/notebook.ts +35 -87
  161. package/src/tools/oracle.ts +25 -25
  162. package/src/tools/output-meta.ts +89 -4
  163. package/src/tools/output-utils.ts +2 -2
  164. package/src/tools/python.ts +86 -637
  165. package/src/tools/read.ts +36 -119
  166. package/src/tools/reviewer-tool.ts +19 -21
  167. package/src/tools/search-code.ts +128 -0
  168. package/src/tools/ssh.ts +67 -126
  169. package/src/tools/subagent-tool.ts +197 -123
  170. package/src/tools/todo-write.ts +15 -31
  171. package/src/tools/tool-errors.ts +0 -30
  172. package/src/tools/undo-edit.ts +30 -67
  173. package/src/tools/write.ts +78 -127
  174. package/src/tui/code-cell.ts +4 -4
  175. package/src/tui/file-list.ts +2 -2
  176. package/src/tui/output-block.ts +1 -1
  177. package/src/tui/status-line.ts +1 -1
  178. package/src/tui/tree-list.ts +2 -2
  179. package/src/tui/types.ts +1 -1
  180. package/src/tui/utils.ts +1 -1
  181. package/src/{tools → ui}/render-utils.ts +87 -126
  182. package/src/utils/external-editor.ts +4 -4
  183. package/src/utils/file-mentions.ts +1 -1
  184. package/src/utils/index.ts +30 -0
  185. package/src/utils/tools-manager.ts +9 -19
  186. package/src/web/github-client.ts +290 -0
  187. package/src/web/scrapers/github.ts +11 -62
  188. package/src/web/search/auth.ts +1 -3
  189. package/src/web/search/index.ts +82 -46
  190. package/src/web/search/provider.ts +11 -16
  191. package/src/web/search/providers/grep.ts +160 -0
  192. package/src/web/search/render.ts +48 -235
  193. package/src/web/search/types.ts +1 -1
  194. package/src/commands/commit.ts +0 -36
  195. package/src/commit/agentic/agent.ts +0 -311
  196. package/src/commit/agentic/fallback.ts +0 -96
  197. package/src/commit/agentic/index.ts +0 -359
  198. package/src/commit/agentic/prompts/analyze-file.md +0 -22
  199. package/src/commit/agentic/prompts/session-user.md +0 -25
  200. package/src/commit/agentic/prompts/split-confirm.md +0 -1
  201. package/src/commit/agentic/prompts/system.md +0 -38
  202. package/src/commit/agentic/state.ts +0 -69
  203. package/src/commit/agentic/tools/analyze-file.ts +0 -118
  204. package/src/commit/agentic/tools/git-file-diff.ts +0 -194
  205. package/src/commit/agentic/tools/git-hunk.ts +0 -50
  206. package/src/commit/agentic/tools/git-overview.ts +0 -84
  207. package/src/commit/agentic/tools/index.ts +0 -56
  208. package/src/commit/agentic/tools/propose-changelog.ts +0 -128
  209. package/src/commit/agentic/tools/propose-commit.ts +0 -154
  210. package/src/commit/agentic/tools/recent-commits.ts +0 -81
  211. package/src/commit/agentic/tools/split-commit.ts +0 -280
  212. package/src/commit/agentic/topo-sort.ts +0 -44
  213. package/src/commit/agentic/trivial.ts +0 -51
  214. package/src/commit/agentic/validation.ts +0 -200
  215. package/src/commit/analysis/conventional.ts +0 -165
  216. package/src/commit/analysis/index.ts +0 -4
  217. package/src/commit/analysis/scope.ts +0 -242
  218. package/src/commit/analysis/summary.ts +0 -112
  219. package/src/commit/analysis/validation.ts +0 -66
  220. package/src/commit/changelog/detect.ts +0 -37
  221. package/src/commit/changelog/generate.ts +0 -110
  222. package/src/commit/changelog/index.ts +0 -234
  223. package/src/commit/changelog/parse.ts +0 -44
  224. package/src/commit/cli.ts +0 -93
  225. package/src/commit/git/diff.ts +0 -148
  226. package/src/commit/git/errors.ts +0 -9
  227. package/src/commit/git/index.ts +0 -211
  228. package/src/commit/git/operations.ts +0 -54
  229. package/src/commit/index.ts +0 -5
  230. package/src/commit/map-reduce/index.ts +0 -64
  231. package/src/commit/map-reduce/map-phase.ts +0 -178
  232. package/src/commit/map-reduce/reduce-phase.ts +0 -145
  233. package/src/commit/map-reduce/utils.ts +0 -9
  234. package/src/commit/message.ts +0 -11
  235. package/src/commit/model-selection.ts +0 -69
  236. package/src/commit/pipeline.ts +0 -243
  237. package/src/commit/prompts/analysis-system.md +0 -148
  238. package/src/commit/prompts/analysis-user.md +0 -38
  239. package/src/commit/prompts/changelog-system.md +0 -50
  240. package/src/commit/prompts/changelog-user.md +0 -18
  241. package/src/commit/prompts/file-observer-system.md +0 -24
  242. package/src/commit/prompts/file-observer-user.md +0 -8
  243. package/src/commit/prompts/reduce-system.md +0 -50
  244. package/src/commit/prompts/reduce-user.md +0 -17
  245. package/src/commit/prompts/summary-retry.md +0 -3
  246. package/src/commit/prompts/summary-system.md +0 -38
  247. package/src/commit/prompts/summary-user.md +0 -13
  248. package/src/commit/prompts/types-description.md +0 -2
  249. package/src/commit/types.ts +0 -109
  250. package/src/commit/utils/exclusions.ts +0 -42
  251. package/src/mcp/render.ts +0 -123
  252. package/src/modes/components/agent-dashboard.ts +0 -1130
  253. package/src/modes/components/codemode-group.ts +0 -369
  254. package/src/modes/components/read-tool-group.ts +0 -119
  255. package/src/modes/components/visual-truncate.ts +0 -63
  256. package/src/prompts/system/subagent-user-prompt.md +0 -8
  257. package/src/prompts/tools/ask.md +0 -44
  258. package/src/prompts/tools/bash.md +0 -24
  259. package/src/prompts/tools/browser.md +0 -33
  260. package/src/prompts/tools/calculator.md +0 -12
  261. package/src/prompts/tools/explore.md +0 -29
  262. package/src/prompts/tools/fetch.md +0 -16
  263. package/src/prompts/tools/find.md +0 -18
  264. package/src/prompts/tools/gemini-image.md +0 -23
  265. package/src/prompts/tools/grep.md +0 -28
  266. package/src/prompts/tools/hashline.md +0 -232
  267. package/src/prompts/tools/librarian.md +0 -24
  268. package/src/prompts/tools/lsp.md +0 -28
  269. package/src/prompts/tools/oracle.md +0 -26
  270. package/src/prompts/tools/patch.md +0 -74
  271. package/src/prompts/tools/python.md +0 -66
  272. package/src/prompts/tools/read.md +0 -36
  273. package/src/prompts/tools/replace.md +0 -38
  274. package/src/prompts/tools/reviewer.md +0 -41
  275. package/src/prompts/tools/ssh.md +0 -51
  276. package/src/prompts/tools/task-summary.md +0 -28
  277. package/src/prompts/tools/task.md +0 -146
  278. package/src/prompts/tools/todo-write.md +0 -65
  279. package/src/prompts/tools/undo-edit.md +0 -7
  280. package/src/prompts/tools/web-search.md +0 -19
  281. package/src/prompts/tools/write.md +0 -18
  282. package/src/task/batch.ts +0 -102
  283. package/src/task/discovery.ts +0 -126
  284. package/src/task/parallel.ts +0 -84
  285. package/src/task/template.ts +0 -32
  286. package/src/tools/calculator.ts +0 -537
  287. package/src/tools/jtd-to-typescript.ts +0 -198
  288. package/src/tools/renderers.ts +0 -60
  289. package/src/tools/tool-result.ts +0 -86
  290. /package/src/{modes/theme → theme}/dark.json +0 -0
  291. /package/src/{modes/theme → theme}/defaults/dark-catppuccin.json +0 -0
  292. /package/src/{modes/theme → theme}/defaults/dark-dracula.json +0 -0
  293. /package/src/{modes/theme → theme}/defaults/dark-gruvbox.json +0 -0
  294. /package/src/{modes/theme → theme}/defaults/dark-solarized.json +0 -0
  295. /package/src/{modes/theme → theme}/defaults/dark-tokyo-night.json +0 -0
  296. /package/src/{modes/theme → theme}/defaults/index.ts +0 -0
  297. /package/src/{modes/theme → theme}/defaults/light-catppuccin.json +0 -0
  298. /package/src/{modes/theme → theme}/defaults/light-github.json +0 -0
  299. /package/src/{modes/theme → theme}/defaults/light-solarized.json +0 -0
  300. /package/src/{modes/theme → theme}/light.json +0 -0
  301. /package/src/{modes/theme → theme}/mermaid-cache.ts +0 -0
  302. /package/src/{modes/theme → theme}/theme-schema.json +0 -0
  303. /package/src/{modes/theme → theme}/theme.ts +0 -0
@@ -14,7 +14,6 @@
14
14
  */
15
15
 
16
16
  import * as fs from "node:fs";
17
- import * as path from "node:path";
18
17
 
19
18
  import {
20
19
  type Agent,
@@ -32,17 +31,13 @@ import type {
32
31
  Model,
33
32
  ProviderSessionState,
34
33
  TextContent,
35
- ToolCall,
36
34
  ToolChoice,
37
- Usage,
38
35
  UsageReport,
39
36
  } from "@nghyane/arcane-ai";
40
- import { isContextOverflow, modelsAreEqual, supportsXhigh } from "@nghyane/arcane-ai";
37
+ import { isContextOverflow, modelsAreEqual } from "@nghyane/arcane-ai";
41
38
  import { abortableSleep, isEnoent, logger } from "@nghyane/arcane-utils";
42
39
  import { getAgentDbPath } from "@nghyane/arcane-utils/dirs";
43
- import type { Rule } from "../capability/rule";
44
- import { MODEL_ROLE_IDS, type ModelRegistry, type ModelRole } from "../config/model-registry";
45
- import { expandRoleAlias, parseModelString } from "../config/model-resolver";
40
+ import type { ModelRegistry, ModelRole } from "../config/model-registry";
46
41
  import {
47
42
  expandPromptTemplate,
48
43
  type PromptTemplate,
@@ -51,7 +46,6 @@ import {
51
46
  } from "../config/prompt-templates";
52
47
  import type { Settings, SkillsSettings } from "../config/settings";
53
48
  import { type BashResult, executeBash as executeBashCommand } from "../exec/bash-executor";
54
- import { exportSessionToHtml } from "../export/html";
55
49
  import type { TtsrManager, TtsrMatchContext } from "../export/ttsr";
56
50
  import type { LoadedCustomCommand } from "../extensibility/custom-commands";
57
51
  import type { CustomTool, CustomToolContext } from "../extensibility/custom-tools/types";
@@ -80,14 +74,12 @@ import type { HookCommandContext } from "../extensibility/hooks/types";
80
74
  import type { Skill, SkillWarning } from "../extensibility/skills";
81
75
  import { expandSlashCommand, type FileSlashCommand } from "../extensibility/slash-commands";
82
76
  import { executePython as executePythonCommand, type PythonResult } from "../ipy/executor";
83
- import { getCurrentThemeName, theme } from "../modes/theme/theme";
84
- import { normalizeDiff, normalizeToLF, ParseError, previewPatch, stripBom } from "../patch";
85
- import ttsrInterruptTemplate from "../prompts/system/ttsr-interrupt.md" with { type: "text" };
77
+ import verificationReminderTemplate from "../prompts/system/verification-reminder.md" with { type: "text" };
86
78
  import type { SecretObfuscator } from "../secrets/obfuscator";
87
79
  import { closeAllConnections } from "../ssh/connection-manager";
88
80
  import { unmountAll } from "../ssh/sshfs-mount";
81
+ import { theme } from "../theme/theme";
89
82
  import { outputMeta } from "../tools/output-meta";
90
- import { resolveToCwd } from "../tools/path-utils";
91
83
  import type { TodoItem } from "../tools/todo-write";
92
84
  import { resolveFileDisplayMode } from "../utils/file-display-mode";
93
85
  import { extractFileMentions, generateFileMentionMessages } from "../utils/file-mentions";
@@ -96,134 +88,58 @@ import {
96
88
  calculateContextTokens,
97
89
  collectEntriesForBranchSummary,
98
90
  compact,
99
- estimateTokens,
100
91
  generateBranchSummary,
101
92
  prepareCompaction,
102
93
  shouldCompact,
103
94
  } from "./compaction";
104
95
  import { DEFAULT_PRUNE_CONFIG, pruneToolOutputs } from "./compaction/pruning";
105
- import {
106
- type BashExecutionMessage,
107
- type BranchSummaryMessage,
108
- bashExecutionToText,
109
- type CompactionSummaryMessage,
110
- type CustomMessage,
111
- type FileMentionMessage,
112
- type HookMessage,
113
- type PythonExecutionMessage,
114
- pythonExecutionToText,
115
- } from "./messages";
96
+ import type { BashExecutionMessage, CustomMessage, PythonExecutionMessage } from "./messages";
97
+ import { ModelController } from "./model-controller";
98
+ import { isRetryableErrorMessage, isUsageLimitErrorMessage, parseRetryAfterMs } from "./retry-utils";
116
99
  import type { BranchSummaryEntry, CompactionEntry, NewSessionOptions, SessionManager } from "./session-manager";
117
100
  import { getLatestCompactionEntry } from "./session-manager";
118
-
119
- /** Session-specific events that extend the core AgentEvent */
120
- export type AgentSessionEvent =
121
- | AgentEvent
122
- | { type: "auto_compaction_start"; reason: "threshold" | "overflow" }
123
- | {
124
- type: "auto_compaction_end";
125
- result: CompactionResult | undefined;
126
- aborted: boolean;
127
- willRetry: boolean;
128
- errorMessage?: string;
129
- }
130
- | { type: "auto_retry_start"; attempt: number; maxAttempts: number; delayMs: number; errorMessage: string }
131
- | { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string }
132
- | { type: "ttsr_triggered"; rules: Rule[] }
133
- | { type: "todo_reminder"; todos: TodoItem[]; attempt: number; maxAttempts: number };
134
-
135
- /** Listener function for agent session events */
136
- export type AgentSessionEventListener = (event: AgentSessionEvent) => void;
137
-
138
- // ============================================================================
139
- // Types
140
- // ============================================================================
141
-
142
- export interface AgentSessionConfig {
143
- agent: Agent;
144
- sessionManager: SessionManager;
145
- settings: Settings;
146
- /** Models to cycle through with Ctrl+P (from --models flag) */
147
- scopedModels?: Array<{ model: Model; thinkingLevel: ThinkingLevel }>;
148
- /** Prompt templates for expansion */
149
- promptTemplates?: PromptTemplate[];
150
- /** File-based slash commands for expansion */
151
- slashCommands?: FileSlashCommand[];
152
- /** Extension runner (created in main.ts with wrapped tools) */
153
- extensionRunner?: ExtensionRunner;
154
- /** Loaded skills (already discovered by SDK) */
155
- skills?: Skill[];
156
- /** Skill loading warnings (already captured by SDK) */
157
- skillWarnings?: SkillWarning[];
158
- /** Custom commands (TypeScript slash commands) */
159
- customCommands?: LoadedCustomCommand[];
160
- skillsSettings?: Required<SkillsSettings>;
161
- /** Model registry for API key resolution and model discovery */
162
- modelRegistry: ModelRegistry;
163
- /** Tool registry for LSP and settings */
164
- toolRegistry?: Map<string, AgentTool>;
165
- /** System prompt builder that can consider tool availability */
166
- rebuildSystemPrompt?: (toolNames: string[], tools: Map<string, AgentTool>) => Promise<string>;
167
- /** TTSR manager for time-traveling stream rules */
168
- ttsrManager?: TtsrManager;
169
- /** Force X-Initiator: agent for GitHub Copilot model selections in this session. */
170
- forceCopilotAgentInitiator?: boolean;
171
- /** Secret obfuscator for deobfuscating streaming edit content */
172
- obfuscator?: SecretObfuscator;
173
- }
174
-
175
- /** Options for AgentSession.prompt() */
176
- export interface PromptOptions {
177
- /** Whether to expand file-based prompt templates (default: true) */
178
- expandPromptTemplates?: boolean;
179
- /** Image attachments */
180
- images?: ImageContent[];
181
- /** When streaming, how to queue the message: "steer" (interrupt) or "followUp" (wait). */
182
- streamingBehavior?: "steer" | "followUp";
183
- /** Optional tool choice override for the next LLM call. */
184
- toolChoice?: ToolChoice;
185
- /** Mark the user message as synthetic (system-injected). */
186
- synthetic?: boolean;
187
- }
188
-
189
- /** Result from cycleModel() */
190
- export interface ModelCycleResult {
191
- model: Model;
192
- thinkingLevel: ThinkingLevel;
193
- /** Whether cycling through scoped models (--models flag) or all available */
194
- isScoped: boolean;
195
- }
196
-
197
- /** Result from cycleRoleModels() */
198
- export interface RoleModelCycleResult {
199
- model: Model;
200
- thinkingLevel: ThinkingLevel;
201
- role: ModelRole;
202
- }
203
-
204
- /** Session statistics for /session command */
205
- export interface SessionStats {
206
- sessionFile: string | undefined;
207
- sessionId: string;
208
- userMessages: number;
209
- assistantMessages: number;
210
- toolCalls: number;
211
- toolResults: number;
212
- totalMessages: number;
213
- tokens: {
214
- input: number;
215
- output: number;
216
- cacheRead: number;
217
- cacheWrite: number;
218
- total: number;
219
- };
220
- cost: number;
221
- }
222
-
223
- /** Result from handoff() */
224
- export interface HandoffResult {
225
- document: string;
226
- }
101
+ import type {
102
+ AgentSessionConfig,
103
+ AgentSessionEvent,
104
+ AgentSessionEventListener,
105
+ HandoffResult,
106
+ ModelCycleResult,
107
+ PromptOptions,
108
+ RoleModelCycleResult,
109
+ SessionStats,
110
+ } from "./session-types";
111
+ import * as sessionStats from "./stats";
112
+ import {
113
+ createStreamingEditState,
114
+ invalidateFileCacheForPath,
115
+ maybeAbortStreamingEdit,
116
+ preCacheStreamingEditFile,
117
+ resetStreamingEditState,
118
+ rewriteToolCallArgs,
119
+ } from "./streaming-edit";
120
+ import {
121
+ addPendingTtsrInjections,
122
+ createTtsrState,
123
+ extractTtsrRuleNames,
124
+ findTtsrAssistantIndex,
125
+ getTtsrInjectionContent,
126
+ getTtsrToolMatchContext,
127
+ markTtsrInjected,
128
+ queueDeferredTtsrInjectionIfNeeded,
129
+ shouldInterruptForTtsrMatch,
130
+ } from "./ttsr";
131
+
132
+ // Re-export types for downstream consumers
133
+ export type {
134
+ AgentSessionConfig,
135
+ AgentSessionEvent,
136
+ AgentSessionEventListener,
137
+ HandoffResult,
138
+ ModelCycleResult,
139
+ PromptOptions,
140
+ RoleModelCycleResult,
141
+ SessionStats,
142
+ };
227
143
 
228
144
  /** Internal marker for hook messages queued through the agent loop */
229
145
  // ============================================================================
@@ -231,10 +147,6 @@ export interface HandoffResult {
231
147
  // ============================================================================
232
148
 
233
149
  /** Standard thinking levels */
234
- const THINKING_LEVELS: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high"];
235
-
236
- /** Thinking levels including xhigh (for supported models) */
237
- const THINKING_LEVELS_WITH_XHIGH: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"];
238
150
 
239
151
  const noOpUIContext: ExtensionUIContext = {
240
152
  select: async (_title, _options, _dialogOptions) => undefined,
@@ -282,7 +194,7 @@ export class AgentSession {
282
194
  readonly sessionManager: SessionManager;
283
195
  readonly settings: Settings;
284
196
 
285
- #scopedModels: Array<{ model: Model; thinkingLevel: ThinkingLevel }>;
197
+ #model: ModelController;
286
198
  #promptTemplates: PromptTemplate[];
287
199
  #slashCommands: FileSlashCommand[];
288
200
 
@@ -317,6 +229,10 @@ export class AgentSession {
317
229
  // Todo completion reminder state
318
230
  #todoReminderCount = 0;
319
231
 
232
+ // Verification loop state
233
+ #verificationReminderCount = 0;
234
+ #turnHasFileModifications = false;
235
+
320
236
  // Bash execution state
321
237
  #bashAbortController: AbortController | undefined = undefined;
322
238
  #pendingBashMessages: BashExecutionMessage[] = [];
@@ -337,34 +253,28 @@ export class AgentSession {
337
253
 
338
254
  #skillsSettings: Required<SkillsSettings> | undefined;
339
255
 
340
- // Model registry for API key resolution
341
- #modelRegistry: ModelRegistry;
342
-
343
256
  // Tool registry and prompt builder for extensions
344
257
  #toolRegistry: Map<string, AgentTool>;
345
258
  #rebuildSystemPrompt: ((toolNames: string[], tools: Map<string, AgentTool>) => Promise<string>) | undefined;
346
259
  #baseSystemPrompt: string;
347
- #forceCopilotAgentInitiator = false;
348
260
 
349
261
  // TTSR manager for time-traveling stream rules
350
262
  #ttsrManager: TtsrManager | undefined = undefined;
351
- #pendingTtsrInjections: Rule[] = [];
352
- #ttsrAbortPending = false;
353
- #ttsrRetryToken = 0;
263
+ #ttsr = createTtsrState();
354
264
 
355
- #streamingEditAbortTriggered = false;
356
- #streamingEditCheckedLineCounts = new Map<string, number>();
357
- #streamingEditFileCache = new Map<string, string>();
265
+ #streamingEdit = createStreamingEditState();
358
266
  #promptInFlight = false;
359
267
  #obfuscator: SecretObfuscator | undefined;
360
268
  #promptGeneration = 0;
361
- #providerSessionState = new Map<string, ProviderSessionState>();
362
269
 
363
270
  constructor(config: AgentSessionConfig) {
364
271
  this.agent = config.agent;
365
272
  this.sessionManager = config.sessionManager;
366
273
  this.settings = config.settings;
367
- this.#scopedModels = config.scopedModels ?? [];
274
+ this.#model = new ModelController(config.agent, config.settings, config.sessionManager, config.modelRegistry, {
275
+ scopedModels: config.scopedModels,
276
+ forceCopilotAgentInitiator: config.forceCopilotAgentInitiator,
277
+ });
368
278
  this.#promptTemplates = config.promptTemplates ?? [];
369
279
  this.#slashCommands = config.slashCommands ?? [];
370
280
  this.#extensionRunner = config.extensionRunner;
@@ -372,14 +282,12 @@ export class AgentSession {
372
282
  this.#skillWarnings = config.skillWarnings ?? [];
373
283
  this.#customCommands = config.customCommands ?? [];
374
284
  this.#skillsSettings = config.skillsSettings;
375
- this.#modelRegistry = config.modelRegistry;
376
285
  this.#toolRegistry = config.toolRegistry ?? new Map();
377
286
  this.#rebuildSystemPrompt = config.rebuildSystemPrompt;
378
287
  this.#baseSystemPrompt = this.agent.state.systemPrompt;
379
288
  this.#ttsrManager = config.ttsrManager;
380
- this.#forceCopilotAgentInitiator = config.forceCopilotAgentInitiator ?? false;
381
289
  this.#obfuscator = config.obfuscator;
382
- this.agent.providerSessionState = this.#providerSessionState;
290
+ this.agent.providerSessionState = this.#model.providerSessionState;
383
291
 
384
292
  // Always subscribe to agent events for internal handling
385
293
  // (session persistence, hooks, auto-compaction, retry logic)
@@ -388,12 +296,12 @@ export class AgentSession {
388
296
 
389
297
  /** Model registry for API key resolution and model discovery */
390
298
  get modelRegistry(): ModelRegistry {
391
- return this.#modelRegistry;
299
+ return this.#model.registry;
392
300
  }
393
301
 
394
302
  /** Provider-scoped mutable state store for transport/session caches. */
395
303
  get providerSessionState(): Map<string, ProviderSessionState> {
396
- return this.#providerSessionState;
304
+ return this.#model.providerSessionState;
397
305
  }
398
306
 
399
307
  /** TTSR manager for time-traveling stream rules */
@@ -403,7 +311,7 @@ export class AgentSession {
403
311
 
404
312
  /** Whether a TTSR abort is pending (stream was aborted to inject rules) */
405
313
  get isTtsrAbortPending(): boolean {
406
- return this.#ttsrAbortPending;
314
+ return this.#ttsr.abortPending;
407
315
  }
408
316
 
409
317
  // =========================================================================
@@ -451,7 +359,7 @@ export class AgentSession {
451
359
  await this.#emitSessionEvent(event);
452
360
 
453
361
  if (event.type === "turn_start") {
454
- this.#resetStreamingEditState();
362
+ resetStreamingEditState(this.#streamingEdit);
455
363
  // TTSR: Reset buffer on turn start
456
364
  this.#ttsrManager?.resetBuffer();
457
365
  }
@@ -471,7 +379,11 @@ export class AgentSession {
471
379
  } else if (assistantEvent.type === "thinking_delta") {
472
380
  matchContext = { source: "thinking" };
473
381
  } else if (assistantEvent.type === "toolcall_delta") {
474
- matchContext = this.#getTtsrToolMatchContext(event.message, assistantEvent.contentIndex);
382
+ matchContext = getTtsrToolMatchContext(
383
+ event.message,
384
+ assistantEvent.contentIndex,
385
+ this.sessionManager.getCwd(),
386
+ );
475
387
  }
476
388
 
477
389
  if (matchContext && "delta" in assistantEvent) {
@@ -479,42 +391,45 @@ export class AgentSession {
479
391
  if (matches.length > 0) {
480
392
  // Queue rules for injection; mark as injected only after successful enqueue.
481
393
 
482
- this.#addPendingTtsrInjections(matches);
394
+ addPendingTtsrInjections(this.#ttsr, matches);
483
395
 
484
- if (this.#shouldInterruptForTtsrMatch(matchContext)) {
396
+ if (shouldInterruptForTtsrMatch(this.#ttsrManager, matchContext)) {
485
397
  // Abort the stream immediately — do not gate on extension callbacks
486
- this.#ttsrAbortPending = true;
398
+ this.#ttsr.abortPending = true;
487
399
  this.agent.abort();
488
400
  // Notify extensions (fire-and-forget, does not block abort)
489
401
  this.#emitSessionEvent({ type: "ttsr_triggered", rules: matches }).catch(() => {});
490
402
  // Schedule retry after a short delay
491
- const retryToken = ++this.#ttsrRetryToken;
403
+ const retryToken = ++this.#ttsr.retryToken;
492
404
  const generation = this.#promptGeneration;
493
405
  const targetMessageTimestamp =
494
406
  event.message.role === "assistant" ? event.message.timestamp : undefined;
495
407
  setTimeout(async () => {
496
- if (this.#ttsrRetryToken !== retryToken) {
408
+ if (this.#ttsr.retryToken !== retryToken) {
497
409
  return;
498
410
  }
499
411
 
500
- const targetAssistantIndex = this.#findTtsrAssistantIndex(targetMessageTimestamp);
412
+ const targetAssistantIndex = findTtsrAssistantIndex(
413
+ this.agent.state.messages,
414
+ targetMessageTimestamp,
415
+ );
501
416
  if (
502
- !this.#ttsrAbortPending ||
417
+ !this.#ttsr.abortPending ||
503
418
  this.#promptGeneration !== generation ||
504
419
  targetAssistantIndex === -1
505
420
  ) {
506
- this.#ttsrAbortPending = false;
507
- this.#pendingTtsrInjections = [];
421
+ this.#ttsr.abortPending = false;
422
+ this.#ttsr.pendingInjections = [];
508
423
  return;
509
424
  }
510
- this.#ttsrAbortPending = false;
425
+ this.#ttsr.abortPending = false;
511
426
  const ttsrSettings = this.#ttsrManager?.getSettings();
512
427
  if (ttsrSettings?.contextMode === "discard") {
513
428
  // Remove the partial/aborted assistant turn from agent state
514
429
  this.agent.replaceMessages(this.agent.state.messages.slice(0, targetAssistantIndex));
515
430
  }
516
431
  // Inject TTSR rules as system reminder before retry
517
- const injection = this.#getTtsrInjectionContent();
432
+ const injection = getTtsrInjectionContent(this.#ttsr);
518
433
  if (injection) {
519
434
  const details = { rules: injection.rules.map(rule => rule.name) };
520
435
  this.agent.appendMessage({
@@ -531,7 +446,7 @@ export class AgentSession {
531
446
  false,
532
447
  details,
533
448
  );
534
- this.#markTtsrInjected(details.rules);
449
+ markTtsrInjected(this.#ttsrManager, this.sessionManager, details.rules);
535
450
  }
536
451
  this.agent.continue().catch(() => {});
537
452
  }, 50);
@@ -542,14 +457,21 @@ export class AgentSession {
542
457
  }
543
458
 
544
459
  if (event.type === "message_update" && event.assistantMessageEvent.type === "toolcall_start") {
545
- this.#preCacheStreamingEditFile(event);
460
+ preCacheStreamingEditFile(event, this.#streamingEdit, this.settings, this.sessionManager.getCwd());
546
461
  }
547
462
 
548
463
  if (
549
464
  event.type === "message_update" &&
550
465
  (event.assistantMessageEvent.type === "toolcall_end" || event.assistantMessageEvent.type === "toolcall_delta")
551
466
  ) {
552
- this.#maybeAbortStreamingEdit(event);
467
+ maybeAbortStreamingEdit(
468
+ event,
469
+ this.#streamingEdit,
470
+ this.settings,
471
+ this.agent,
472
+ this.sessionManager.getCwd(),
473
+ this.#obfuscator,
474
+ );
553
475
  }
554
476
 
555
477
  // Handle session persistence
@@ -564,7 +486,7 @@ export class AgentSession {
564
486
  event.message.details,
565
487
  );
566
488
  if (event.message.role === "custom" && event.message.customType === "ttsr-injection") {
567
- this.#markTtsrInjected(this.#extractTtsrRuleNames(event.message.details));
489
+ markTtsrInjected(this.#ttsrManager, this.sessionManager, extractTtsrRuleNames(event.message.details));
568
490
  }
569
491
  } else if (
570
492
  event.message.role === "user" ||
@@ -581,7 +503,7 @@ export class AgentSession {
581
503
  if (event.message.role === "assistant") {
582
504
  this.#lastAssistantMessage = event.message;
583
505
  const assistantMsg = event.message as AssistantMessage;
584
- this.#queueDeferredTtsrInjectionIfNeeded(assistantMsg);
506
+ queueDeferredTtsrInjectionIfNeeded(this.#ttsr, this.agent, assistantMsg);
585
507
  if (this.#handoffAbortController) {
586
508
  this.#skipPostTurnMaintenanceAssistantTimestamp = assistantMsg.timestamp;
587
509
  }
@@ -610,11 +532,15 @@ export class AgentSession {
610
532
  content?: Array<TextContent | ImageContent>;
611
533
  };
612
534
  if ($normative && toolCallId && this.settings.get("normativeRewrite")) {
613
- await this.#rewriteToolCallArgs(toolCallId, $normative);
535
+ await rewriteToolCallArgs(this.agent, this.sessionManager, toolCallId, $normative);
614
536
  }
615
537
  // Invalidate streaming edit cache when edit tool completes to prevent stale data
616
538
  if (toolName === "edit" && details?.path) {
617
- this.#invalidateFileCacheForPath(details.path);
539
+ invalidateFileCacheForPath(this.#streamingEdit, details.path, this.sessionManager.getCwd());
540
+ }
541
+ // Track file modifications for auto-verification
542
+ if ((toolName === "edit" || toolName === "write") && !isError) {
543
+ this.#turnHasFileModifications = true;
618
544
  }
619
545
  if (toolName === "todo_write" && isError) {
620
546
  const errorText = content?.find(part => part.type === "text")?.text;
@@ -656,6 +582,12 @@ export class AgentSession {
656
582
 
657
583
  await this.#checkCompaction(msg);
658
584
 
585
+ // Check verification (if agent modified files without verifying)
586
+ if (msg.stopReason !== "error" && msg.stopReason !== "aborted") {
587
+ const didRemind = await this.#checkVerification();
588
+ if (didRemind) return;
589
+ }
590
+
659
591
  // Check for incomplete todos (unless there was an error or abort)
660
592
  if (msg.stopReason !== "error" && msg.stopReason !== "aborted") {
661
593
  await this.#checkTodoCompletion();
@@ -671,186 +603,6 @@ export class AgentSession {
671
603
  this.#retryPromise = undefined;
672
604
  }
673
605
  }
674
-
675
- /** Get TTSR injection payload and clear pending injections. */
676
- #getTtsrInjectionContent(): { content: string; rules: Rule[] } | undefined {
677
- if (this.#pendingTtsrInjections.length === 0) return undefined;
678
- const rules = this.#pendingTtsrInjections;
679
- const content = rules
680
- .map(r => renderPromptTemplate(ttsrInterruptTemplate, { name: r.name, path: r.path, content: r.content }))
681
- .join("\n\n");
682
- this.#pendingTtsrInjections = [];
683
- return { content, rules };
684
- }
685
-
686
- #addPendingTtsrInjections(rules: Rule[]): void {
687
- const seen = new Set(this.#pendingTtsrInjections.map(rule => rule.name));
688
- for (const rule of rules) {
689
- if (seen.has(rule.name)) continue;
690
- this.#pendingTtsrInjections.push(rule);
691
- seen.add(rule.name);
692
- }
693
- }
694
-
695
- #extractTtsrRuleNames(details: unknown): string[] {
696
- if (!details || typeof details !== "object" || Array.isArray(details)) {
697
- return [];
698
- }
699
- const rules = (details as { rules?: unknown }).rules;
700
- if (!Array.isArray(rules)) {
701
- return [];
702
- }
703
- return rules.filter((ruleName): ruleName is string => typeof ruleName === "string");
704
- }
705
-
706
- #markTtsrInjected(ruleNames: string[]): void {
707
- const uniqueRuleNames = Array.from(
708
- new Set(ruleNames.map(ruleName => ruleName.trim()).filter(ruleName => ruleName.length > 0)),
709
- );
710
- if (uniqueRuleNames.length === 0) {
711
- return;
712
- }
713
- this.#ttsrManager?.markInjectedByNames(uniqueRuleNames);
714
- this.sessionManager.appendTtsrInjection(uniqueRuleNames);
715
- }
716
-
717
- #findTtsrAssistantIndex(targetTimestamp: number | undefined): number {
718
- const messages = this.agent.state.messages;
719
- for (let i = messages.length - 1; i >= 0; i--) {
720
- const message = messages[i];
721
- if (message.role !== "assistant") {
722
- continue;
723
- }
724
- if (targetTimestamp === undefined || message.timestamp === targetTimestamp) {
725
- return i;
726
- }
727
- }
728
- return -1;
729
- }
730
-
731
- #shouldInterruptForTtsrMatch(matchContext: TtsrMatchContext): boolean {
732
- const mode = this.#ttsrManager?.getSettings().interruptMode ?? "always";
733
- if (mode === "never") {
734
- return false;
735
- }
736
- if (mode === "prose-only") {
737
- return matchContext.source === "text" || matchContext.source === "thinking";
738
- }
739
- if (mode === "tool-only") {
740
- return matchContext.source === "tool";
741
- }
742
- return true;
743
- }
744
-
745
- #queueDeferredTtsrInjectionIfNeeded(assistantMsg: AssistantMessage): void {
746
- if (this.#ttsrAbortPending || this.#pendingTtsrInjections.length === 0) {
747
- return;
748
- }
749
- if (assistantMsg.stopReason === "aborted" || assistantMsg.stopReason === "error") {
750
- this.#pendingTtsrInjections = [];
751
- return;
752
- }
753
-
754
- const injection = this.#getTtsrInjectionContent();
755
- if (!injection) {
756
- return;
757
- }
758
- this.agent.followUp({
759
- role: "custom",
760
- customType: "ttsr-injection",
761
- content: injection.content,
762
- display: false,
763
- details: { rules: injection.rules.map(rule => rule.name) },
764
- timestamp: Date.now(),
765
- });
766
- // Mark as injected after this custom message is delivered and persisted (handled in message_end).
767
- // followUp() only enqueues; resume on the next tick once streaming settles.
768
- setTimeout(() => {
769
- if (this.agent.state.isStreaming || !this.agent.hasQueuedMessages()) {
770
- return;
771
- }
772
- this.agent.continue().catch(() => {});
773
- }, 0);
774
- }
775
-
776
- /** Build TTSR match context for tool call argument deltas. */
777
- #getTtsrToolMatchContext(message: AgentMessage, contentIndex: number): TtsrMatchContext {
778
- const context: TtsrMatchContext = { source: "tool" };
779
- if (message.role !== "assistant") {
780
- return context;
781
- }
782
-
783
- const content = message.content;
784
- if (!Array.isArray(content) || contentIndex < 0 || contentIndex >= content.length) {
785
- return context;
786
- }
787
-
788
- const block = content[contentIndex];
789
- if (!block || typeof block !== "object" || block.type !== "toolCall") {
790
- return context;
791
- }
792
-
793
- const toolCall = block as ToolCall;
794
- context.toolName = toolCall.name;
795
- context.streamKey = toolCall.id ? `toolcall:${toolCall.id}` : `tool:${toolCall.name}:${contentIndex}`;
796
- context.filePaths = this.#extractTtsrFilePathsFromArgs(toolCall.arguments);
797
- return context;
798
- }
799
-
800
- /** Extract path-like arguments from tool call payload for TTSR glob matching. */
801
- #extractTtsrFilePathsFromArgs(args: unknown): string[] | undefined {
802
- if (!args || typeof args !== "object" || Array.isArray(args)) {
803
- return undefined;
804
- }
805
-
806
- const rawPaths: string[] = [];
807
- for (const [key, value] of Object.entries(args)) {
808
- const normalizedKey = key.toLowerCase();
809
- if (typeof value === "string" && (normalizedKey === "path" || normalizedKey.endsWith("path"))) {
810
- rawPaths.push(value);
811
- continue;
812
- }
813
- if (Array.isArray(value) && (normalizedKey === "paths" || normalizedKey.endsWith("paths"))) {
814
- for (const candidate of value) {
815
- if (typeof candidate === "string") {
816
- rawPaths.push(candidate);
817
- }
818
- }
819
- }
820
- }
821
-
822
- const normalizedPaths = rawPaths.flatMap(pathValue => this.#normalizeTtsrPathCandidates(pathValue));
823
- if (normalizedPaths.length === 0) {
824
- return undefined;
825
- }
826
-
827
- return Array.from(new Set(normalizedPaths));
828
- }
829
-
830
- /** Convert a path argument into stable relative/absolute candidates for glob checks. */
831
- #normalizeTtsrPathCandidates(rawPath: string): string[] {
832
- const trimmed = rawPath.trim();
833
- if (trimmed.length === 0) {
834
- return [];
835
- }
836
-
837
- const normalizedInput = trimmed.replaceAll("\\", "/");
838
- const candidates = new Set<string>([normalizedInput]);
839
- if (normalizedInput.startsWith("./")) {
840
- candidates.add(normalizedInput.slice(2));
841
- }
842
-
843
- const cwd = this.sessionManager.getCwd();
844
- const absolutePath = path.isAbsolute(trimmed) ? path.normalize(trimmed) : path.resolve(cwd, trimmed);
845
- candidates.add(absolutePath.replaceAll("\\", "/"));
846
-
847
- const relativePath = path.relative(cwd, absolutePath).replaceAll("\\", "/");
848
- if (relativePath && relativePath !== "." && !relativePath.startsWith("../") && relativePath !== "..") {
849
- candidates.add(relativePath);
850
- }
851
-
852
- return Array.from(candidates);
853
- }
854
606
  /** Extract text content from a message */
855
607
  #getUserMessageText(message: Message): string {
856
608
  if (message.role !== "user") return "";
@@ -875,216 +627,6 @@ export class AgentSession {
875
627
  return undefined;
876
628
  }
877
629
 
878
- #resetStreamingEditState(): void {
879
- this.#streamingEditAbortTriggered = false;
880
- this.#streamingEditCheckedLineCounts.clear();
881
- this.#streamingEditFileCache.clear();
882
- }
883
-
884
- async #preCacheStreamingEditFile(event: AgentEvent): Promise<void> {
885
- if (!this.settings.get("edit.streamingAbort")) return;
886
- if (event.type !== "message_update") return;
887
- const assistantEvent = event.assistantMessageEvent;
888
- if (assistantEvent.type !== "toolcall_start") return;
889
- if (event.message.role !== "assistant") return;
890
-
891
- const contentIndex = assistantEvent.contentIndex;
892
- const messageContent = event.message.content;
893
- if (!Array.isArray(messageContent) || contentIndex >= messageContent.length) return;
894
- const toolCall = messageContent[contentIndex] as ToolCall;
895
- if (toolCall.name !== "edit") return;
896
-
897
- const args = toolCall.arguments;
898
- if (!args || typeof args !== "object" || Array.isArray(args)) return;
899
- if ("old_text" in args || "new_text" in args) return;
900
-
901
- const path = typeof args.path === "string" ? args.path : undefined;
902
- if (!path) return;
903
-
904
- const resolvedPath = resolveToCwd(path, this.sessionManager.getCwd());
905
- this.#ensureFileCache(resolvedPath);
906
- }
907
-
908
- #ensureFileCache(resolvedPath: string): void {
909
- if (this.#streamingEditFileCache.has(resolvedPath)) return;
910
-
911
- try {
912
- const rawText = fs.readFileSync(resolvedPath, "utf-8");
913
- const { text } = stripBom(rawText);
914
- this.#streamingEditFileCache.set(resolvedPath, normalizeToLF(text));
915
- } catch {
916
- // Don't cache on read errors (including ENOENT) - let the edit tool handle them
917
- }
918
- }
919
-
920
- /** Invalidate cache for a file after an edit completes to prevent stale data */
921
- #invalidateFileCacheForPath(path: string): void {
922
- const resolvedPath = resolveToCwd(path, this.sessionManager.getCwd());
923
- this.#streamingEditFileCache.delete(resolvedPath);
924
- }
925
-
926
- #maybeAbortStreamingEdit(event: AgentEvent): void {
927
- if (!this.settings.get("edit.streamingAbort")) return;
928
- if (this.#streamingEditAbortTriggered) return;
929
- if (event.type !== "message_update") return;
930
- const assistantEvent = event.assistantMessageEvent;
931
- if (assistantEvent.type !== "toolcall_end" && assistantEvent.type !== "toolcall_delta") return;
932
- if (event.message.role !== "assistant") return;
933
-
934
- const contentIndex = assistantEvent.contentIndex;
935
- const messageContent = event.message.content;
936
- if (!Array.isArray(messageContent) || contentIndex >= messageContent.length) return;
937
- const toolCall = messageContent[contentIndex] as ToolCall;
938
- if (toolCall.name !== "edit" || !toolCall.id) return;
939
-
940
- const args = toolCall.arguments;
941
- if (!args || typeof args !== "object" || Array.isArray(args)) return;
942
- if ("old_text" in args || "new_text" in args) return;
943
-
944
- const path = typeof args.path === "string" ? args.path : undefined;
945
- const diff = typeof args.diff === "string" ? args.diff : undefined;
946
- const op = typeof args.op === "string" ? args.op : undefined;
947
- if (!path || !diff) return;
948
- if (op && op !== "update") return;
949
-
950
- if (!diff.includes("\n")) return;
951
- const lastNewlineIndex = diff.lastIndexOf("\n");
952
- if (lastNewlineIndex < 0) return;
953
- const diffForCheck = diff.endsWith("\n") ? diff : diff.slice(0, lastNewlineIndex + 1);
954
- if (diffForCheck.trim().length === 0) return;
955
-
956
- let normalizedDiff = normalizeDiff(diffForCheck.replace(/\r/g, ""));
957
- if (!normalizedDiff) return;
958
- // Deobfuscate the diff so removed lines match real file content
959
- if (this.#obfuscator) normalizedDiff = this.#obfuscator.deobfuscate(normalizedDiff);
960
- if (!normalizedDiff) return;
961
- const lines = normalizedDiff.split("\n");
962
- const hasChangeLine = lines.some(line => line.startsWith("+") || line.startsWith("-"));
963
- if (!hasChangeLine) return;
964
-
965
- const lineCount = lines.length;
966
- const lastChecked = this.#streamingEditCheckedLineCounts.get(toolCall.id);
967
- if (lastChecked !== undefined && lineCount <= lastChecked) return;
968
- this.#streamingEditCheckedLineCounts.set(toolCall.id, lineCount);
969
-
970
- const rename = typeof args.rename === "string" ? args.rename : undefined;
971
-
972
- const removedLines = lines
973
- .filter(line => line.startsWith("-") && !line.startsWith("--- "))
974
- .map(line => line.slice(1));
975
- if (removedLines.length > 0) {
976
- const resolvedPath = resolveToCwd(path, this.sessionManager.getCwd());
977
- let cachedContent = this.#streamingEditFileCache.get(resolvedPath);
978
- if (cachedContent === undefined) {
979
- this.#ensureFileCache(resolvedPath);
980
- cachedContent = this.#streamingEditFileCache.get(resolvedPath);
981
- }
982
- if (cachedContent !== undefined) {
983
- const missing = removedLines.find(line => !cachedContent.includes(normalizeToLF(line)));
984
- if (missing) {
985
- this.#streamingEditAbortTriggered = true;
986
- logger.warn("Streaming edit aborted due to patch preview failure", {
987
- toolCallId: toolCall.id,
988
- path,
989
- error: `Failed to find expected lines in ${path}:\n${missing}`,
990
- });
991
- this.agent.abort();
992
- }
993
- return;
994
- }
995
- if (assistantEvent.type === "toolcall_delta") return;
996
- void this.#checkRemovedLinesAsync(toolCall.id, path, resolvedPath, removedLines);
997
- return;
998
- }
999
-
1000
- if (assistantEvent.type === "toolcall_delta") return;
1001
- void this.#checkPreviewPatchAsync(toolCall.id, path, rename, normalizedDiff);
1002
- }
1003
-
1004
- async #checkRemovedLinesAsync(
1005
- toolCallId: string,
1006
- path: string,
1007
- resolvedPath: string,
1008
- removedLines: string[],
1009
- ): Promise<void> {
1010
- if (this.#streamingEditAbortTriggered) return;
1011
- try {
1012
- const { text } = stripBom(await Bun.file(resolvedPath).text());
1013
- const normalizedContent = normalizeToLF(text);
1014
- const missing = removedLines.find(line => !normalizedContent.includes(normalizeToLF(line)));
1015
- if (missing) {
1016
- this.#streamingEditAbortTriggered = true;
1017
- logger.warn("Streaming edit aborted due to patch preview failure", {
1018
- toolCallId,
1019
- path,
1020
- error: `Failed to find expected lines in ${path}:\n${missing}`,
1021
- });
1022
- this.agent.abort();
1023
- }
1024
- } catch (err) {
1025
- // Ignore ENOENT (file not found) - let the edit tool handle missing files
1026
- // Also ignore other errors during async fallback
1027
- if (!isEnoent(err)) {
1028
- // Log unexpected errors but don't abort
1029
- }
1030
- }
1031
- }
1032
-
1033
- async #checkPreviewPatchAsync(
1034
- toolCallId: string,
1035
- path: string,
1036
- rename: string | undefined,
1037
- normalizedDiff: string,
1038
- ): Promise<void> {
1039
- if (this.#streamingEditAbortTriggered) return;
1040
- try {
1041
- await previewPatch(
1042
- { path, op: "update", rename, diff: normalizedDiff },
1043
- {
1044
- cwd: this.sessionManager.getCwd(),
1045
- allowFuzzy: this.settings.get("edit.fuzzyMatch"),
1046
- fuzzyThreshold: this.settings.get("edit.fuzzyThreshold"),
1047
- },
1048
- );
1049
- } catch (error) {
1050
- if (error instanceof ParseError) return;
1051
- this.#streamingEditAbortTriggered = true;
1052
- logger.warn("Streaming edit aborted due to patch preview failure", {
1053
- toolCallId,
1054
- path,
1055
- error: error instanceof Error ? error.message : String(error),
1056
- });
1057
- this.agent.abort();
1058
- }
1059
- }
1060
-
1061
- /** Rewrite tool call arguments in agent state and persisted session history. */
1062
- async #rewriteToolCallArgs(toolCallId: string, args: Record<string, unknown>): Promise<void> {
1063
- let updated = false;
1064
- const messages = this.agent.state.messages;
1065
- for (let i = messages.length - 1; i >= 0; i--) {
1066
- const msg = messages[i];
1067
- if (msg.role !== "assistant") continue;
1068
- const assistantMsg = msg as AssistantMessage;
1069
- if (!Array.isArray(assistantMsg.content)) continue;
1070
- for (const block of assistantMsg.content) {
1071
- if (typeof block !== "object" || block === null) continue;
1072
- if (!("type" in block) || (block as { type?: string }).type !== "toolCall") continue;
1073
- const toolCall = block as { id?: string; arguments?: Record<string, unknown> };
1074
- if (toolCall.id === toolCallId) {
1075
- toolCall.arguments = args;
1076
- updated = true;
1077
- break;
1078
- }
1079
- }
1080
- if (updated) break;
1081
- }
1082
-
1083
- if (updated) {
1084
- await this.sessionManager.rewriteAssistantToolCallArgs(toolCallId, args);
1085
- }
1086
- }
1087
-
1088
630
  /** Emit extension events based on session events */
1089
631
  async #emitExtensionEvent(event: AgentSessionEvent): Promise<void> {
1090
632
  if (!this.#extensionRunner) return;
@@ -1237,10 +779,10 @@ export class AgentSession {
1237
779
  async dispose(): Promise<void> {
1238
780
  await this.sessionManager.flush();
1239
781
  await cleanupSshResources();
1240
- for (const state of this.#providerSessionState.values()) {
782
+ for (const state of this.#model.providerSessionState.values()) {
1241
783
  state.close();
1242
784
  }
1243
- this.#providerSessionState.clear();
785
+ this.#model.providerSessionState.clear();
1244
786
  this.#disconnectFromAgent();
1245
787
  this.#eventListeners = [];
1246
788
  }
@@ -1258,20 +800,6 @@ export class AgentSession {
1258
800
  get model(): Model | undefined {
1259
801
  return this.agent.state.model;
1260
802
  }
1261
-
1262
- #applySessionModelOverrides(model: Model): Model {
1263
- if (!this.#forceCopilotAgentInitiator || model.provider !== "github-copilot") {
1264
- return model;
1265
- }
1266
- return {
1267
- ...model,
1268
- headers: {
1269
- ...model.headers,
1270
- "X-Initiator": "agent",
1271
- },
1272
- };
1273
- }
1274
-
1275
803
  /** Current thinking level */
1276
804
  get thinkingLevel(): ThinkingLevel {
1277
805
  return this.agent.state.thinkingLevel;
@@ -1367,7 +895,7 @@ export class AgentSession {
1367
895
 
1368
896
  const getCustomToolContext = (): CustomToolContext => ({
1369
897
  sessionManager: this.sessionManager,
1370
- modelRegistry: this.#modelRegistry,
898
+ modelRegistry: this.#model.registry,
1371
899
  model: this.model,
1372
900
  isIdle: () => !this.isStreaming,
1373
901
  hasQueuedMessages: () => this.queuedMessageCount > 0,
@@ -1440,11 +968,11 @@ export class AgentSession {
1440
968
 
1441
969
  /** Scoped models for cycling (from --models flag) */
1442
970
  get scopedModels(): ReadonlyArray<{ model: Model; thinkingLevel: ThinkingLevel }> {
1443
- return this.#scopedModels;
971
+ return this.#model.scopedModels;
1444
972
  }
1445
973
 
1446
974
  resolveRoleModel(role: ModelRole): Model | undefined {
1447
- return this.#resolveRoleModel(role, this.#modelRegistry.getAvailable(), this.model);
975
+ return this.#model.resolveRoleModel(role);
1448
976
  }
1449
977
 
1450
978
  get promptTemplates(): ReadonlyArray<PromptTemplate> {
@@ -1579,6 +1107,8 @@ export class AgentSession {
1579
1107
 
1580
1108
  // Reset todo reminder count on new user prompt
1581
1109
  this.#todoReminderCount = 0;
1110
+ this.#verificationReminderCount = 0;
1111
+ this.#turnHasFileModifications = false;
1582
1112
 
1583
1113
  // Validate model
1584
1114
  if (!this.model) {
@@ -1590,7 +1120,7 @@ export class AgentSession {
1590
1120
  }
1591
1121
 
1592
1122
  // Validate API key
1593
- const apiKey = await this.#modelRegistry.getApiKey(this.model, this.sessionId);
1123
+ const apiKey = await this.#model.registry.getApiKey(this.model, this.sessionId);
1594
1124
  if (!apiKey) {
1595
1125
  throw new Error(
1596
1126
  `No API key found for ${this.model.provider}.\n\n` +
@@ -1712,7 +1242,7 @@ export class AgentSession {
1712
1242
  hasUI: false,
1713
1243
  cwd: this.sessionManager.getCwd(),
1714
1244
  sessionManager: this.sessionManager,
1715
- modelRegistry: this.#modelRegistry,
1245
+ modelRegistry: this.#model.registry,
1716
1246
  model: this.model ?? undefined,
1717
1247
  isIdle: () => !this.isStreaming,
1718
1248
  abort: () => {
@@ -2164,278 +1694,47 @@ export class AgentSession {
2164
1694
  // Model Management
2165
1695
  // =========================================================================
2166
1696
 
2167
- /**
2168
- * Set model directly.
2169
- * Validates API key, saves to session and settings.
2170
- * @throws Error if no API key available for the model
2171
- */
2172
1697
  async setModel(model: Model, role: ModelRole = "default"): Promise<void> {
2173
- const apiKey = await this.#modelRegistry.getApiKey(model, this.sessionId);
2174
- if (!apiKey) {
2175
- throw new Error(`No API key for ${model.provider}/${model.id}`);
2176
- }
2177
-
2178
- this.#setModelWithProviderSessionReset(model);
2179
- this.sessionManager.appendModelChange(`${model.provider}/${model.id}`, role);
2180
- this.settings.setModelRole(role, `${model.provider}/${model.id}`);
2181
- this.settings.getStorage()?.recordModelUsage(`${model.provider}/${model.id}`);
2182
-
2183
- // Re-clamp thinking level for new model's capabilities without persisting settings
2184
- this.setThinkingLevel(this.thinkingLevel);
1698
+ return this.#model.setModel(model, role);
2185
1699
  }
2186
1700
 
2187
- /**
2188
- * Set model temporarily (for this session only).
2189
- * Validates API key, saves to session log but NOT to settings.
2190
- * @throws Error if no API key available for the model
2191
- */
2192
1701
  async setModelTemporary(model: Model): Promise<void> {
2193
- const apiKey = await this.#modelRegistry.getApiKey(model, this.sessionId);
2194
- if (!apiKey) {
2195
- throw new Error(`No API key for ${model.provider}/${model.id}`);
2196
- }
2197
-
2198
- this.#setModelWithProviderSessionReset(model);
2199
- this.sessionManager.appendModelChange(`${model.provider}/${model.id}`, "temporary");
2200
- this.settings.getStorage()?.recordModelUsage(`${model.provider}/${model.id}`);
2201
-
2202
- // Re-clamp thinking level for new model's capabilities without persisting settings
2203
- this.setThinkingLevel(this.thinkingLevel);
1702
+ return this.#model.setModelTemporary(model);
2204
1703
  }
2205
1704
 
2206
- /**
2207
- * Cycle to next/previous model.
2208
- * Uses scoped models (from --models flag) if available, otherwise all available models.
2209
- * @param direction - "forward" (default) or "backward"
2210
- * @returns The new model info, or undefined if only one model available
2211
- */
2212
1705
  async cycleModel(direction: "forward" | "backward" = "forward"): Promise<ModelCycleResult | undefined> {
2213
- if (this.#scopedModels.length > 0) {
2214
- return this.#cycleScopedModel(direction);
2215
- }
2216
- return this.#cycleAvailableModel(direction);
1706
+ return this.#model.cycleModel(direction);
2217
1707
  }
2218
1708
 
2219
- /**
2220
- * Cycle through configured role models in a fixed order.
2221
- * Skips missing roles.
2222
- * @param roleOrder - Order of roles to cycle through (e.g., ["oracle", "default", "fast"])
2223
- * @param options - Optional settings: `temporary` to not persist to settings
2224
- */
2225
1709
  async cycleRoleModels(
2226
1710
  roleOrder: readonly ModelRole[],
2227
1711
  options?: { temporary?: boolean },
2228
1712
  ): Promise<RoleModelCycleResult | undefined> {
2229
- const availableModels = this.#modelRegistry.getAvailable();
2230
- if (availableModels.length === 0) return undefined;
2231
-
2232
- const currentModel = this.model;
2233
- if (!currentModel) return undefined;
2234
- const roleModels: Array<{ role: ModelRole; model: Model }> = [];
2235
-
2236
- for (const role of roleOrder) {
2237
- const roleModelStr =
2238
- role === "default"
2239
- ? (this.settings.getModelRole("default") ?? `${currentModel.provider}/${currentModel.id}`)
2240
- : this.settings.getModelRole(role);
2241
- if (!roleModelStr) continue;
2242
-
2243
- const expandedRoleModelStr = expandRoleAlias(roleModelStr, this.settings);
2244
- const parsed = parseModelString(expandedRoleModelStr);
2245
- let match: Model | undefined;
2246
- if (parsed) {
2247
- match = availableModels.find(m => m.provider === parsed.provider && m.id === parsed.id);
2248
- }
2249
- if (!match) {
2250
- match = availableModels.find(m => m.id.toLowerCase() === expandedRoleModelStr.toLowerCase());
2251
- }
2252
- if (!match) continue;
2253
-
2254
- roleModels.push({ role, model: match });
2255
- }
2256
-
2257
- if (roleModels.length <= 1) return undefined;
2258
-
2259
- const lastRole = this.sessionManager.getLastModelChangeRole();
2260
- let currentIndex = lastRole
2261
- ? roleModels.findIndex(entry => entry.role === lastRole)
2262
- : roleModels.findIndex(entry => modelsAreEqual(entry.model, currentModel));
2263
- if (currentIndex === -1) currentIndex = 0;
2264
-
2265
- const nextIndex = (currentIndex + 1) % roleModels.length;
2266
- const next = roleModels[nextIndex];
2267
-
2268
- if (options?.temporary) {
2269
- await this.setModelTemporary(next.model);
2270
- } else {
2271
- await this.setModel(next.model, next.role);
2272
- }
2273
-
2274
- return { model: next.model, thinkingLevel: this.thinkingLevel, role: next.role };
2275
- }
2276
-
2277
- async #getScopedModelsWithApiKey(): Promise<Array<{ model: Model; thinkingLevel: ThinkingLevel }>> {
2278
- const apiKeysByProvider = new Map<string, string | undefined>();
2279
- const result: Array<{ model: Model; thinkingLevel: ThinkingLevel }> = [];
2280
-
2281
- for (const scoped of this.#scopedModels) {
2282
- const provider = scoped.model.provider;
2283
- let apiKey: string | undefined;
2284
- if (apiKeysByProvider.has(provider)) {
2285
- apiKey = apiKeysByProvider.get(provider);
2286
- } else {
2287
- apiKey = await this.#modelRegistry.getApiKeyForProvider(provider, this.sessionId);
2288
- apiKeysByProvider.set(provider, apiKey);
2289
- }
2290
-
2291
- if (apiKey) {
2292
- result.push(scoped);
2293
- }
2294
- }
2295
-
2296
- return result;
2297
- }
2298
-
2299
- async #cycleScopedModel(direction: "forward" | "backward"): Promise<ModelCycleResult | undefined> {
2300
- const scopedModels = await this.#getScopedModelsWithApiKey();
2301
- if (scopedModels.length <= 1) return undefined;
2302
-
2303
- const currentModel = this.model;
2304
- let currentIndex = scopedModels.findIndex(sm => modelsAreEqual(sm.model, currentModel));
2305
-
2306
- if (currentIndex === -1) currentIndex = 0;
2307
- const len = scopedModels.length;
2308
- const nextIndex = direction === "forward" ? (currentIndex + 1) % len : (currentIndex - 1 + len) % len;
2309
- const next = scopedModels[nextIndex];
2310
-
2311
- // Apply model
2312
- this.#setModelWithProviderSessionReset(next.model);
2313
- this.sessionManager.appendModelChange(`${next.model.provider}/${next.model.id}`);
2314
- this.settings.setModelRole("default", `${next.model.provider}/${next.model.id}`);
2315
- this.settings.getStorage()?.recordModelUsage(`${next.model.provider}/${next.model.id}`);
2316
-
2317
- // Apply thinking level (setThinkingLevel clamps to model capabilities)
2318
- this.setThinkingLevel(next.thinkingLevel);
2319
-
2320
- return { model: next.model, thinkingLevel: this.thinkingLevel, isScoped: true };
2321
- }
2322
-
2323
- async #cycleAvailableModel(direction: "forward" | "backward"): Promise<ModelCycleResult | undefined> {
2324
- const availableModels = this.#modelRegistry.getAvailable();
2325
- if (availableModels.length <= 1) return undefined;
2326
-
2327
- const currentModel = this.model;
2328
- let currentIndex = availableModels.findIndex(m => modelsAreEqual(m, currentModel));
2329
-
2330
- if (currentIndex === -1) currentIndex = 0;
2331
- const len = availableModels.length;
2332
- const nextIndex = direction === "forward" ? (currentIndex + 1) % len : (currentIndex - 1 + len) % len;
2333
- const nextModel = availableModels[nextIndex];
2334
-
2335
- const apiKey = await this.#modelRegistry.getApiKey(nextModel, this.sessionId);
2336
- if (!apiKey) {
2337
- throw new Error(`No API key for ${nextModel.provider}/${nextModel.id}`);
2338
- }
2339
-
2340
- this.#setModelWithProviderSessionReset(nextModel);
2341
- this.sessionManager.appendModelChange(`${nextModel.provider}/${nextModel.id}`);
2342
- this.settings.setModelRole("default", `${nextModel.provider}/${nextModel.id}`);
2343
- this.settings.getStorage()?.recordModelUsage(`${nextModel.provider}/${nextModel.id}`);
2344
-
2345
- // Re-clamp thinking level for new model's capabilities without persisting settings
2346
- this.setThinkingLevel(this.thinkingLevel);
2347
-
2348
- return { model: nextModel, thinkingLevel: this.thinkingLevel, isScoped: false };
1713
+ return this.#model.cycleRoleModels(roleOrder, options);
2349
1714
  }
2350
1715
 
2351
- /**
2352
- * Get all available models with valid API keys.
2353
- */
2354
1716
  getAvailableModels(): Model[] {
2355
- return this.#modelRegistry.getAvailable();
1717
+ return this.#model.getAvailableModels();
2356
1718
  }
2357
1719
 
2358
- // =========================================================================
2359
- // Thinking Level Management
2360
- // =========================================================================
2361
-
2362
- /**
2363
- * Set thinking level.
2364
- * Clamps to model capabilities based on available thinking levels.
2365
- * Saves to session and settings only if the level actually changes.
2366
- */
2367
- setThinkingLevel(level: ThinkingLevel, persist: boolean = false): void {
2368
- const availableLevels = this.getAvailableThinkingLevels();
2369
- const effectiveLevel = availableLevels.includes(level) ? level : this.#clampThinkingLevel(level, availableLevels);
2370
-
2371
- // Only persist if actually changing
2372
- const isChanging = effectiveLevel !== this.agent.state.thinkingLevel;
2373
-
2374
- this.agent.setThinkingLevel(effectiveLevel);
2375
-
2376
- if (isChanging) {
2377
- this.sessionManager.appendThinkingLevelChange(effectiveLevel);
2378
- if (persist) {
2379
- this.settings.set("defaultThinkingLevel", effectiveLevel);
2380
- }
2381
- }
1720
+ setThinkingLevel(level: ThinkingLevel, persist = false): void {
1721
+ this.#model.setThinkingLevel(level, persist);
2382
1722
  }
2383
1723
 
2384
- /**
2385
- * Cycle to next thinking level.
2386
- * @returns New level, or undefined if model doesn't support thinking
2387
- */
2388
1724
  cycleThinkingLevel(): ThinkingLevel | undefined {
2389
- if (!this.supportsThinking()) return undefined;
2390
-
2391
- const levels = this.getAvailableThinkingLevels();
2392
- const currentIndex = levels.indexOf(this.thinkingLevel);
2393
- const nextIndex = (currentIndex + 1) % levels.length;
2394
- const nextLevel = levels[nextIndex];
2395
-
2396
- this.setThinkingLevel(nextLevel);
2397
- return nextLevel;
1725
+ return this.#model.cycleThinkingLevel();
2398
1726
  }
2399
1727
 
2400
- /**
2401
- * Get available thinking levels for current model.
2402
- * The provider will clamp to what the specific model supports internally.
2403
- */
2404
1728
  getAvailableThinkingLevels(): ThinkingLevel[] {
2405
- if (!this.supportsThinking()) return ["off"];
2406
- return this.supportsXhighThinking() ? THINKING_LEVELS_WITH_XHIGH : THINKING_LEVELS;
1729
+ return this.#model.getAvailableThinkingLevels();
2407
1730
  }
2408
1731
 
2409
- /**
2410
- * Check if current model supports xhigh thinking level.
2411
- */
2412
1732
  supportsXhighThinking(): boolean {
2413
- return this.model ? supportsXhigh(this.model) : false;
1733
+ return this.#model.supportsXhighThinking();
2414
1734
  }
2415
1735
 
2416
- /**
2417
- * Check if current model supports thinking/reasoning.
2418
- */
2419
1736
  supportsThinking(): boolean {
2420
- return !!this.model?.reasoning;
2421
- }
2422
-
2423
- #clampThinkingLevel(level: ThinkingLevel, availableLevels: ThinkingLevel[]): ThinkingLevel {
2424
- const ordered = THINKING_LEVELS_WITH_XHIGH;
2425
- const available = new Set(availableLevels);
2426
- const requestedIndex = ordered.indexOf(level);
2427
- if (requestedIndex === -1) {
2428
- return availableLevels[0] ?? "off";
2429
- }
2430
- for (let i = requestedIndex; i < ordered.length; i++) {
2431
- const candidate = ordered[i];
2432
- if (available.has(candidate)) return candidate;
2433
- }
2434
- for (let i = requestedIndex - 1; i >= 0; i--) {
2435
- const candidate = ordered[i];
2436
- if (available.has(candidate)) return candidate;
2437
- }
2438
- return availableLevels[0] ?? "off";
1737
+ return this.#model.supportsThinking();
2439
1738
  }
2440
1739
 
2441
1740
  // =========================================================================
@@ -2483,7 +1782,7 @@ export class AgentSession {
2483
1782
  await this.sessionManager.rewriteEntries();
2484
1783
  const sessionContext = this.sessionManager.buildSessionContext();
2485
1784
  this.agent.replaceMessages(sessionContext.messages);
2486
- this.#closeCodexProviderSessionsForHistoryRewrite();
1785
+ this.#model.closeCodexProviderSessionsForHistoryRewrite();
2487
1786
  return result;
2488
1787
  }
2489
1788
 
@@ -2505,7 +1804,7 @@ export class AgentSession {
2505
1804
 
2506
1805
  const compactionSettings = this.settings.getGroup("compaction");
2507
1806
  const compactionModel = this.model;
2508
- const apiKey = await this.#modelRegistry.getApiKey(compactionModel, this.sessionId);
1807
+ const apiKey = await this.#model.registry.getApiKey(compactionModel, this.sessionId);
2509
1808
  if (!apiKey) {
2510
1809
  throw new Error(`No API key for ${compactionModel.provider}`);
2511
1810
  }
@@ -2607,7 +1906,7 @@ export class AgentSession {
2607
1906
  const newEntries = this.sessionManager.getEntries();
2608
1907
  const sessionContext = this.sessionManager.buildSessionContext();
2609
1908
  this.agent.replaceMessages(sessionContext.messages);
2610
- this.#closeCodexProviderSessionsForHistoryRewrite();
1909
+ this.#model.closeCodexProviderSessionsForHistoryRewrite();
2611
1910
 
2612
1911
  // Get the saved compaction entry for the hook
2613
1912
  const savedCompactionEntry = newEntries.find(e => e.type === "compaction" && e.summary === summary) as
@@ -2731,34 +2030,32 @@ Be thorough - include exact file paths, function names, error messages, and tech
2731
2030
 
2732
2031
  // Create a promise that resolves when the agent completes
2733
2032
  let handoffText: string | undefined;
2734
- const completionPromise = new Promise<void>((resolve, reject) => {
2735
- const unsubscribe = this.subscribe(event => {
2736
- if (this.#handoffAbortController?.signal.aborted) {
2737
- unsubscribe();
2738
- reject(new Error("Handoff cancelled"));
2739
- return;
2740
- }
2033
+ const { promise: completionPromise, resolve, reject } = Promise.withResolvers<void>();
2034
+ const unsubscribe = this.subscribe(event => {
2035
+ if (this.#handoffAbortController?.signal.aborted) {
2036
+ unsubscribe();
2037
+ reject(new Error("Handoff cancelled"));
2038
+ return;
2039
+ }
2741
2040
 
2742
- if (event.type === "agent_end") {
2743
- unsubscribe();
2744
- // Extract text from the last assistant message
2745
- const messages = this.agent.state.messages;
2746
- for (let i = messages.length - 1; i >= 0; i--) {
2747
- const msg = messages[i];
2748
- if (msg.role === "assistant") {
2749
- const content = (msg as AssistantMessage).content;
2750
- const textParts = content
2751
- .filter((c): c is { type: "text"; text: string } => c.type === "text")
2752
- .map(c => c.text);
2753
- if (textParts.length > 0) {
2754
- handoffText = textParts.join("\n");
2755
- break;
2756
- }
2041
+ if (event.type === "agent_end") {
2042
+ unsubscribe();
2043
+ const messages = this.agent.state.messages;
2044
+ for (let i = messages.length - 1; i >= 0; i--) {
2045
+ const msg = messages[i];
2046
+ if (msg.role === "assistant") {
2047
+ const content = (msg as AssistantMessage).content;
2048
+ const textParts = content
2049
+ .filter((c): c is { type: "text"; text: string } => c.type === "text")
2050
+ .map(c => c.text);
2051
+ if (textParts.length > 0) {
2052
+ handoffText = textParts.join("\n");
2053
+ break;
2757
2054
  }
2758
2055
  }
2759
- resolve();
2760
2056
  }
2761
- });
2057
+ resolve();
2058
+ }
2762
2059
  });
2763
2060
 
2764
2061
  try {
@@ -2867,6 +2164,72 @@ Be thorough - include exact file paths, function names, error messages, and tech
2867
2164
  }
2868
2165
  }
2869
2166
  }
2167
+ /**
2168
+ * Check if agent stopped after modifying files without running verification.
2169
+ * If so, inject a reminder to verify and continue the conversation.
2170
+ */
2171
+ async #checkVerification(): Promise<boolean> {
2172
+ if (!this.settings.get("verification.autoCheck")) {
2173
+ this.#verificationReminderCount = 0;
2174
+ return false;
2175
+ }
2176
+
2177
+ if (!this.#turnHasFileModifications) {
2178
+ return false;
2179
+ }
2180
+
2181
+ const maxReminders = this.settings.get("verification.maxReminders");
2182
+ if (this.#verificationReminderCount >= maxReminders) {
2183
+ logger.debug("Verification: max reminders reached", { count: this.#verificationReminderCount });
2184
+ return false;
2185
+ }
2186
+
2187
+ if (this.#turnHasVerificationCommand()) {
2188
+ this.#verificationReminderCount = 0;
2189
+ this.#turnHasFileModifications = false;
2190
+ return false;
2191
+ }
2192
+
2193
+ this.#verificationReminderCount++;
2194
+ const reminder = renderPromptTemplate(verificationReminderTemplate, {
2195
+ attempt: this.#verificationReminderCount,
2196
+ maxAttempts: maxReminders,
2197
+ });
2198
+
2199
+ logger.debug("Verification: sending reminder", {
2200
+ attempt: this.#verificationReminderCount,
2201
+ });
2202
+
2203
+ this.agent.appendMessage({
2204
+ role: "user",
2205
+ content: [{ type: "text", text: reminder }],
2206
+ timestamp: Date.now(),
2207
+ });
2208
+ this.agent.continue().catch(() => {});
2209
+ return true;
2210
+ }
2211
+
2212
+ /**
2213
+ * Check if the current turn included a bash command that looks like a verification step.
2214
+ */
2215
+ #turnHasVerificationCommand(): boolean {
2216
+ const messages = this.agent.state.messages;
2217
+ for (let i = messages.length - 1; i >= 0; i--) {
2218
+ const msg = messages[i];
2219
+ if (msg.role === "user") break;
2220
+ if (msg.role !== "assistant") continue;
2221
+ const assistant = msg as AssistantMessage;
2222
+ for (const block of assistant.content) {
2223
+ if (block.type !== "toolCall" || block.name !== "bash") continue;
2224
+ const cmd = (block.arguments as { command?: string })?.command ?? "";
2225
+ if (/\b(check|lint|fmt|format|test|typecheck|tsc|biome|eslint|clippy)\b/.test(cmd)) {
2226
+ return true;
2227
+ }
2228
+ }
2229
+ }
2230
+ return false;
2231
+ }
2232
+
2870
2233
  /**
2871
2234
  * Check if agent stopped with incomplete todos and prompt to continue.
2872
2235
  */
@@ -2974,20 +2337,20 @@ Be thorough - include exact file paths, function names, error messages, and tech
2974
2337
  }
2975
2338
 
2976
2339
  async #resolveContextPromotionTarget(currentModel: Model, contextWindow: number): Promise<Model | undefined> {
2977
- const availableModels = this.#modelRegistry.getAvailable();
2340
+ const availableModels = this.#model.registry.getAvailable();
2978
2341
  if (availableModels.length === 0) return undefined;
2979
2342
 
2980
2343
  const candidates: Model[] = [];
2981
2344
  const seen = new Set<string>();
2982
2345
  const addCandidate = (candidate: Model | undefined): void => {
2983
2346
  if (!candidate) return;
2984
- const key = this.#getModelKey(candidate);
2347
+ const key = this.#model.getModelKey(candidate);
2985
2348
  if (seen.has(key)) return;
2986
2349
  seen.add(key);
2987
2350
  candidates.push(candidate);
2988
2351
  };
2989
2352
 
2990
- addCandidate(this.#resolveContextPromotionConfiguredTarget(currentModel, availableModels));
2353
+ addCandidate(this.#model.resolveContextPromotionTarget(currentModel, availableModels));
2991
2354
 
2992
2355
  const sameProviderLarger = [...availableModels]
2993
2356
  .filter(
@@ -2998,109 +2361,13 @@ Be thorough - include exact file paths, function names, error messages, and tech
2998
2361
  for (const candidate of candidates) {
2999
2362
  if (modelsAreEqual(candidate, currentModel)) continue;
3000
2363
  if (candidate.contextWindow <= contextWindow) continue;
3001
- const apiKey = await this.#modelRegistry.getApiKey(candidate, this.sessionId);
2364
+ const apiKey = await this.#model.registry.getApiKey(candidate, this.sessionId);
3002
2365
  if (!apiKey) continue;
3003
2366
  return candidate;
3004
2367
  }
3005
2368
 
3006
2369
  return undefined;
3007
2370
  }
3008
-
3009
- #setModelWithProviderSessionReset(model: Model): void {
3010
- const currentModel = this.model;
3011
- if (currentModel) {
3012
- this.#closeProviderSessionsForModelSwitch(currentModel, model);
3013
- }
3014
- this.agent.setModel(this.#applySessionModelOverrides(model));
3015
- }
3016
-
3017
- #closeCodexProviderSessionsForHistoryRewrite(): void {
3018
- const currentModel = this.model;
3019
- if (!currentModel || currentModel.api !== "openai-codex-responses") return;
3020
- this.#closeProviderSessionsForModelSwitch(currentModel, currentModel);
3021
- }
3022
-
3023
- #closeProviderSessionsForModelSwitch(currentModel: Model, nextModel: Model): void {
3024
- if (currentModel.api !== "openai-codex-responses" && nextModel.api !== "openai-codex-responses") return;
3025
-
3026
- const providerKey = "openai-codex-responses";
3027
- const state = this.#providerSessionState.get(providerKey);
3028
- if (!state) return;
3029
-
3030
- try {
3031
- state.close();
3032
- } catch (error) {
3033
- logger.warn("Failed to close provider session state during model switch", {
3034
- providerKey,
3035
- error: String(error),
3036
- });
3037
- }
3038
-
3039
- this.#providerSessionState.delete(providerKey);
3040
- }
3041
-
3042
- #getModelKey(model: Model): string {
3043
- return `${model.provider}/${model.id}`;
3044
- }
3045
-
3046
- #resolveContextPromotionConfiguredTarget(currentModel: Model, availableModels: Model[]): Model | undefined {
3047
- const configuredTarget = currentModel.contextPromotionTarget?.trim();
3048
- if (!configuredTarget) return undefined;
3049
-
3050
- const parsed = parseModelString(configuredTarget);
3051
- if (parsed) {
3052
- const explicitModel = availableModels.find(m => m.provider === parsed.provider && m.id === parsed.id);
3053
- if (explicitModel) return explicitModel;
3054
- }
3055
-
3056
- return availableModels.find(m => m.provider === currentModel.provider && m.id === configuredTarget);
3057
- }
3058
-
3059
- #resolveRoleModel(role: ModelRole, availableModels: Model[], currentModel: Model | undefined): Model | undefined {
3060
- const roleModelStr =
3061
- role === "default"
3062
- ? (this.settings.getModelRole("default") ??
3063
- (currentModel ? `${currentModel.provider}/${currentModel.id}` : undefined))
3064
- : this.settings.getModelRole(role);
3065
-
3066
- if (!roleModelStr) return undefined;
3067
-
3068
- const parsed = parseModelString(roleModelStr);
3069
- if (parsed) {
3070
- return availableModels.find(m => m.provider === parsed.provider && m.id === parsed.id);
3071
- }
3072
- const roleLower = roleModelStr.toLowerCase();
3073
- return availableModels.find(m => m.id.toLowerCase() === roleLower);
3074
- }
3075
-
3076
- #getCompactionModelCandidates(availableModels: Model[]): Model[] {
3077
- const candidates: Model[] = [];
3078
- const seen = new Set<string>();
3079
-
3080
- const addCandidate = (model: Model | undefined): void => {
3081
- if (!model) return;
3082
- const key = this.#getModelKey(model);
3083
- if (seen.has(key)) return;
3084
- seen.add(key);
3085
- candidates.push(model);
3086
- };
3087
-
3088
- const currentModel = this.model;
3089
- for (const role of MODEL_ROLE_IDS) {
3090
- addCandidate(this.#resolveRoleModel(role, availableModels, currentModel));
3091
- }
3092
-
3093
- const sortedByContext = [...availableModels].sort((a, b) => b.contextWindow - a.contextWindow);
3094
- for (const model of sortedByContext) {
3095
- if (!seen.has(this.#getModelKey(model))) {
3096
- addCandidate(model);
3097
- break;
3098
- }
3099
- }
3100
-
3101
- return candidates;
3102
- }
3103
-
3104
2371
  /**
3105
2372
  * Internal: Run auto-compaction with events.
3106
2373
  */
@@ -3125,7 +2392,7 @@ Be thorough - include exact file paths, function names, error messages, and tech
3125
2392
  return;
3126
2393
  }
3127
2394
 
3128
- const availableModels = this.#modelRegistry.getAvailable();
2395
+ const availableModels = this.#model.registry.getAvailable();
3129
2396
  if (availableModels.length === 0) {
3130
2397
  await this.#emitSessionEvent({
3131
2398
  type: "auto_compaction_end",
@@ -3208,13 +2475,13 @@ Be thorough - include exact file paths, function names, error messages, and tech
3208
2475
  details = hookCompaction.details;
3209
2476
  preserveData ??= hookCompaction.preserveData;
3210
2477
  } else {
3211
- const candidates = this.#getCompactionModelCandidates(availableModels);
2478
+ const candidates = this.#model.getCompactionModelCandidates(availableModels);
3212
2479
  const retrySettings = this.settings.getGroup("retry");
3213
2480
  let compactResult: CompactionResult | undefined;
3214
2481
  let lastError: unknown;
3215
2482
 
3216
2483
  for (const candidate of candidates) {
3217
- const apiKey = await this.#modelRegistry.getApiKey(candidate, this.sessionId);
2484
+ const apiKey = await this.#model.registry.getApiKey(candidate, this.sessionId);
3218
2485
  if (!apiKey) continue;
3219
2486
 
3220
2487
  let attempt = 0;
@@ -3235,11 +2502,11 @@ Be thorough - include exact file paths, function names, error messages, and tech
3235
2502
  }
3236
2503
 
3237
2504
  const message = error instanceof Error ? error.message : String(error);
3238
- const retryAfterMs = this.#parseRetryAfterMsFromError(message);
2505
+ const retryAfterMs = parseRetryAfterMs(message);
3239
2506
  const shouldRetry =
3240
2507
  retrySettings.enabled &&
3241
2508
  attempt < retrySettings.maxRetries &&
3242
- (retryAfterMs !== undefined || this.#isRetryableErrorMessage(message));
2509
+ (retryAfterMs !== undefined || isRetryableErrorMessage(message));
3243
2510
  if (!shouldRetry) {
3244
2511
  lastError = error;
3245
2512
  break;
@@ -3319,7 +2586,7 @@ Be thorough - include exact file paths, function names, error messages, and tech
3319
2586
  const newEntries = this.sessionManager.getEntries();
3320
2587
  const sessionContext = this.sessionManager.buildSessionContext();
3321
2588
  this.agent.replaceMessages(sessionContext.messages);
3322
- this.#closeCodexProviderSessionsForHistoryRewrite();
2589
+ this.#model.closeCodexProviderSessionsForHistoryRewrite();
3323
2590
 
3324
2591
  // Get the saved compaction entry for the hook
3325
2592
  const savedCompactionEntry = newEntries.find(e => e.type === "compaction" && e.summary === summary) as
@@ -3422,65 +2689,8 @@ Be thorough - include exact file paths, function names, error messages, and tech
3422
2689
  if (isContextOverflow(message, contextWindow)) return false;
3423
2690
 
3424
2691
  const err = message.errorMessage;
3425
- return this.#isRetryableErrorMessage(err);
2692
+ return isRetryableErrorMessage(err);
3426
2693
  }
3427
-
3428
- #isRetryableErrorMessage(errorMessage: string): boolean {
3429
- // Match: overloaded_error, rate limit, usage limit, 429, 500, 502, 503, 504, service unavailable, connection error, fetch failed, retry delay exceeded
3430
- return /overloaded|rate.?limit|usage.?limit|too many requests|429|500|502|503|504|service.?unavailable|server error|internal error|connection.?error|unable to connect|fetch failed|retry delay/i.test(
3431
- errorMessage,
3432
- );
3433
- }
3434
-
3435
- #isUsageLimitErrorMessage(errorMessage: string): boolean {
3436
- return /usage.?limit|usage_limit_reached|limit_reached/i.test(errorMessage);
3437
- }
3438
-
3439
- #parseRetryAfterMsFromError(errorMessage: string): number | undefined {
3440
- const now = Date.now();
3441
- const retryAfterMsMatch = /retry-after-ms\s*[:=]\s*(\d+)/i.exec(errorMessage);
3442
- if (retryAfterMsMatch) {
3443
- return Math.max(0, Number(retryAfterMsMatch[1]));
3444
- }
3445
-
3446
- const retryAfterMatch = /retry-after\s*[:=]\s*([^\s,;]+)/i.exec(errorMessage);
3447
- if (retryAfterMatch) {
3448
- const value = retryAfterMatch[1];
3449
- const seconds = Number(value);
3450
- if (!Number.isNaN(seconds)) {
3451
- return Math.max(0, seconds * 1000);
3452
- }
3453
- const dateMs = Date.parse(value);
3454
- if (!Number.isNaN(dateMs)) {
3455
- return Math.max(0, dateMs - now);
3456
- }
3457
- }
3458
-
3459
- const resetMsMatch = /x-ratelimit-reset-ms\s*[:=]\s*(\d+)/i.exec(errorMessage);
3460
- if (resetMsMatch) {
3461
- const resetMs = Number(resetMsMatch[1]);
3462
- if (!Number.isNaN(resetMs)) {
3463
- if (resetMs > 1_000_000_000_000) {
3464
- return Math.max(0, resetMs - now);
3465
- }
3466
- return Math.max(0, resetMs);
3467
- }
3468
- }
3469
-
3470
- const resetMatch = /x-ratelimit-reset\s*[:=]\s*(\d+)/i.exec(errorMessage);
3471
- if (resetMatch) {
3472
- const resetSeconds = Number(resetMatch[1]);
3473
- if (!Number.isNaN(resetSeconds)) {
3474
- if (resetSeconds > 1_000_000_000) {
3475
- return Math.max(0, resetSeconds * 1000 - now);
3476
- }
3477
- return Math.max(0, resetSeconds * 1000);
3478
- }
3479
- }
3480
-
3481
- return undefined;
3482
- }
3483
-
3484
2694
  /**
3485
2695
  * Handle retryable errors with exponential backoff.
3486
2696
  * @returns true if retry was initiated, false if max retries exceeded or disabled
@@ -3515,9 +2725,9 @@ Be thorough - include exact file paths, function names, error messages, and tech
3515
2725
  const errorMessage = message.errorMessage || "Unknown error";
3516
2726
  let delayMs = retrySettings.baseDelayMs * 2 ** (this.#retryAttempt - 1);
3517
2727
 
3518
- if (this.model && this.#isUsageLimitErrorMessage(errorMessage)) {
3519
- const retryAfterMs = this.#parseRetryAfterMsFromError(errorMessage);
3520
- const switched = await this.#modelRegistry.authStorage.markUsageLimitReached(
2728
+ if (this.model && isUsageLimitErrorMessage(errorMessage)) {
2729
+ const retryAfterMs = parseRetryAfterMs(errorMessage);
2730
+ const switched = await this.#model.registry.authStorage.markUsageLimitReached(
3521
2731
  this.model.provider,
3522
2732
  this.sessionId,
3523
2733
  {
@@ -3899,10 +3109,10 @@ Be thorough - include exact file paths, function names, error messages, and tech
3899
3109
  if (slashIdx > 0) {
3900
3110
  const provider = defaultModelStr.slice(0, slashIdx);
3901
3111
  const modelId = defaultModelStr.slice(slashIdx + 1);
3902
- const availableModels = this.#modelRegistry.getAvailable();
3112
+ const availableModels = this.#model.registry.getAvailable();
3903
3113
  const match = availableModels.find(m => m.provider === provider && m.id === modelId);
3904
3114
  if (match) {
3905
- this.#setModelWithProviderSessionReset(match);
3115
+ this.#model.setModelDirect(match);
3906
3116
  }
3907
3117
  }
3908
3118
  }
@@ -3917,7 +3127,7 @@ Be thorough - include exact file paths, function names, error messages, and tech
3917
3127
  const availableLevels = this.getAvailableThinkingLevels();
3918
3128
  const effectiveLevel = availableLevels.includes(defaultThinkingLevel)
3919
3129
  ? defaultThinkingLevel
3920
- : this.#clampThinkingLevel(defaultThinkingLevel, availableLevels);
3130
+ : this.#model.clampThinkingLevel(defaultThinkingLevel, availableLevels);
3921
3131
  this.agent.setThinkingLevel(effectiveLevel);
3922
3132
  this.sessionManager.appendThinkingLevelChange(effectiveLevel);
3923
3133
  }
@@ -4069,7 +3279,7 @@ Be thorough - include exact file paths, function names, error messages, and tech
4069
3279
  let summaryDetails: unknown;
4070
3280
  if (options.summarize && entriesToSummarize.length > 0 && !hookSummary) {
4071
3281
  const model = this.model!;
4072
- const apiKey = await this.#modelRegistry.getApiKey(model, this.sessionId);
3282
+ const apiKey = await this.#model.registry.getApiKey(model, this.sessionId);
4073
3283
  if (!apiKey) {
4074
3284
  throw new Error(`No API key for ${model.provider}`);
4075
3285
  }
@@ -4186,456 +3396,31 @@ Be thorough - include exact file paths, function names, error messages, and tech
4186
3396
  return "";
4187
3397
  }
4188
3398
 
4189
- /**
4190
- * Get session statistics.
4191
- */
4192
3399
  getSessionStats(): SessionStats {
4193
- const state = this.state;
4194
- const userMessages = state.messages.filter(m => m.role === "user").length;
4195
- const assistantMessages = state.messages.filter(m => m.role === "assistant").length;
4196
- const toolResults = state.messages.filter(m => m.role === "toolResult").length;
4197
-
4198
- let toolCalls = 0;
4199
- let totalInput = 0;
4200
- let totalOutput = 0;
4201
- let totalCacheRead = 0;
4202
- let totalCacheWrite = 0;
4203
- let totalCost = 0;
4204
-
4205
- const getTaskToolUsage = (details: unknown): Usage | undefined => {
4206
- if (!details || typeof details !== "object") return undefined;
4207
- const record = details as Record<string, unknown>;
4208
- const usage = record.usage;
4209
- if (!usage || typeof usage !== "object") return undefined;
4210
- return usage as Usage;
4211
- };
4212
-
4213
- for (const message of state.messages) {
4214
- if (message.role === "assistant") {
4215
- const assistantMsg = message as AssistantMessage;
4216
- toolCalls += assistantMsg.content.filter(c => c.type === "toolCall").length;
4217
- totalInput += assistantMsg.usage.input;
4218
- totalOutput += assistantMsg.usage.output;
4219
- totalCacheRead += assistantMsg.usage.cacheRead;
4220
- totalCacheWrite += assistantMsg.usage.cacheWrite;
4221
- totalCost += assistantMsg.usage.cost.total;
4222
- }
4223
-
4224
- if (message.role === "toolResult" && message.toolName === "task") {
4225
- const usage = getTaskToolUsage(message.details);
4226
- if (usage) {
4227
- totalInput += usage.input;
4228
- totalOutput += usage.output;
4229
- totalCacheRead += usage.cacheRead;
4230
- totalCacheWrite += usage.cacheWrite;
4231
- totalCost += usage.cost.total;
4232
- }
4233
- }
4234
- }
4235
-
4236
- return {
4237
- sessionFile: this.sessionFile,
4238
- sessionId: this.sessionId,
4239
- userMessages,
4240
- assistantMessages,
4241
- toolCalls,
4242
- toolResults,
4243
- totalMessages: state.messages.length,
4244
- tokens: {
4245
- input: totalInput,
4246
- output: totalOutput,
4247
- cacheRead: totalCacheRead,
4248
- cacheWrite: totalCacheWrite,
4249
- total: totalInput + totalOutput + totalCacheRead + totalCacheWrite,
4250
- },
4251
- cost: totalCost,
4252
- };
3400
+ return sessionStats.getSessionStats(this.messages, this.sessionFile, this.sessionId);
4253
3401
  }
4254
3402
 
4255
- /**
4256
- * Get current context usage statistics.
4257
- * Uses the last assistant message's usage data when available,
4258
- * otherwise estimates tokens for all messages.
4259
- */
4260
3403
  getContextUsage(): ContextUsage | undefined {
4261
- const model = this.model;
4262
- if (!model) return undefined;
4263
-
4264
- const contextWindow = model.contextWindow ?? 0;
4265
- if (contextWindow <= 0) return undefined;
4266
-
4267
- // After compaction, the last assistant usage reflects pre-compaction context size.
4268
- // We can only trust usage from an assistant that responded after the latest compaction.
4269
- // If no such assistant exists, context token count is unknown until the next LLM response.
4270
- const branchEntries = this.sessionManager.getBranch();
4271
- const latestCompaction = getLatestCompactionEntry(branchEntries);
4272
-
4273
- if (latestCompaction) {
4274
- // Check if there's a valid assistant usage after the compaction boundary
4275
- const compactionIndex = branchEntries.lastIndexOf(latestCompaction);
4276
- let hasPostCompactionUsage = false;
4277
- for (let i = branchEntries.length - 1; i > compactionIndex; i--) {
4278
- const entry = branchEntries[i];
4279
- if (entry.type === "message" && entry.message.role === "assistant") {
4280
- const assistant = entry.message;
4281
- if (assistant.stopReason !== "aborted" && assistant.stopReason !== "error") {
4282
- const contextTokens = calculateContextTokens(assistant.usage);
4283
- if (contextTokens > 0) {
4284
- hasPostCompactionUsage = true;
4285
- }
4286
- break;
4287
- }
4288
- }
4289
- }
4290
-
4291
- if (!hasPostCompactionUsage) {
4292
- return { tokens: null, contextWindow, percent: null };
4293
- }
4294
- }
4295
-
4296
- const estimate = this.#estimateContextTokens();
4297
- const percent = (estimate.tokens / contextWindow) * 100;
4298
-
4299
- return {
4300
- tokens: estimate.tokens,
4301
- contextWindow,
4302
- percent,
4303
- };
3404
+ return sessionStats.getContextUsage(this.model, this.messages, this.sessionManager);
4304
3405
  }
4305
3406
 
4306
3407
  async fetchUsageReports(): Promise<UsageReport[] | null> {
4307
- const authStorage = this.#modelRegistry.authStorage;
4308
- if (!authStorage.fetchUsageReports) return null;
4309
- return authStorage.fetchUsageReports({
4310
- baseUrlResolver: provider => this.#modelRegistry.getProviderBaseUrl?.(provider),
4311
- });
3408
+ return sessionStats.fetchUsageReports(this.#model.registry) ?? null;
4312
3409
  }
4313
-
4314
- /**
4315
- * Estimate context tokens from messages, using the last assistant usage when available.
4316
- */
4317
- #estimateContextTokens(): {
4318
- tokens: number;
4319
- } {
4320
- const messages = this.messages;
4321
-
4322
- // Find last assistant message with usage
4323
- let lastUsageIndex: number | null = null;
4324
- let lastUsage: Usage | undefined;
4325
- for (let i = messages.length - 1; i >= 0; i--) {
4326
- const msg = messages[i];
4327
- if (msg.role === "assistant") {
4328
- const assistantMsg = msg as AssistantMessage;
4329
- if (assistantMsg.usage) {
4330
- lastUsage = assistantMsg.usage;
4331
- lastUsageIndex = i;
4332
- break;
4333
- }
4334
- }
4335
- }
4336
-
4337
- if (!lastUsage || lastUsageIndex === null) {
4338
- // No usage data - estimate all messages
4339
- let estimated = 0;
4340
- for (const message of messages) {
4341
- estimated += estimateTokens(message);
4342
- }
4343
- return {
4344
- tokens: estimated,
4345
- };
4346
- }
4347
-
4348
- const usageTokens = calculateContextTokens(lastUsage);
4349
- let trailingTokens = 0;
4350
- for (let i = lastUsageIndex + 1; i < messages.length; i++) {
4351
- trailingTokens += estimateTokens(messages[i]);
4352
- }
4353
-
4354
- return {
4355
- tokens: usageTokens + trailingTokens,
4356
- };
4357
- }
4358
-
4359
- /**
4360
- * Export session to HTML.
4361
- * @param outputPath Optional output path (defaults to session directory)
4362
- * @returns Path to exported file
4363
- */
4364
3410
  async exportToHtml(outputPath?: string): Promise<string> {
4365
- const themeName = getCurrentThemeName();
4366
- return exportSessionToHtml(this.sessionManager, this.state, { outputPath, themeName });
3411
+ return sessionStats.exportToHtml(this.sessionManager, this.state, outputPath);
4367
3412
  }
4368
3413
 
4369
- // =========================================================================
4370
- // Utilities
4371
- // =========================================================================
4372
-
4373
- /**
4374
- * Get text content of last assistant message.
4375
- * Useful for /copy command.
4376
- * @returns Text content, or undefined if no assistant message exists
4377
- */
4378
3414
  getLastAssistantText(): string | undefined {
4379
- const lastAssistant = this.messages
4380
- .slice()
4381
- .reverse()
4382
- .find(m => {
4383
- if (m.role !== "assistant") return false;
4384
- const msg = m as AssistantMessage;
4385
- // Skip aborted messages with no content
4386
- if (msg.stopReason === "aborted" && msg.content.length === 0) return false;
4387
- return true;
4388
- });
4389
-
4390
- if (!lastAssistant) return undefined;
4391
-
4392
- let text = "";
4393
- for (const content of (lastAssistant as AssistantMessage).content) {
4394
- if (content.type === "text") {
4395
- text += content.text;
4396
- }
4397
- }
4398
-
4399
- return text.trim() || undefined;
3415
+ return sessionStats.getLastAssistantText(this.messages);
4400
3416
  }
4401
3417
 
4402
- /**
4403
- * Format the entire session as plain text for clipboard export.
4404
- * Includes user messages, assistant text, thinking blocks, tool calls, and tool results.
4405
- */
4406
3418
  formatSessionAsText(): string {
4407
- const lines: string[] = [];
4408
-
4409
- /** Serialize an object as XML parameter elements, one per key. */
4410
- function formatArgsAsXml(args: Record<string, unknown>, indent = "\t"): string {
4411
- const parts: string[] = [];
4412
- for (const [key, value] of Object.entries(args)) {
4413
- const text = typeof value === "string" ? value : JSON.stringify(value);
4414
- parts.push(`${indent}<parameter name="${key}">${text}</parameter>`);
4415
- }
4416
- return parts.join("\n");
4417
- }
4418
-
4419
- // Include system prompt at the beginning
4420
- const systemPrompt = this.agent.state.systemPrompt;
4421
- if (systemPrompt) {
4422
- lines.push("## System Prompt\n");
4423
- lines.push(systemPrompt);
4424
- lines.push("\n");
4425
- }
4426
-
4427
- // Include model and thinking level
4428
- const model = this.agent.state.model;
4429
- const thinkingLevel = this.agent.state.thinkingLevel;
4430
- lines.push("## Configuration\n");
4431
- lines.push(`Model: ${model.provider}/${model.id}`);
4432
- lines.push(`Thinking Level: ${thinkingLevel}`);
4433
- lines.push("\n");
4434
-
4435
- // Include available tools
4436
- const tools = this.agent.state.tools;
4437
-
4438
- // Recursively strip all fields starting with 'TypeBox.' from an object
4439
- function stripTypeBoxFields(obj: any): any {
4440
- if (Array.isArray(obj)) {
4441
- return obj.map(stripTypeBoxFields);
4442
- }
4443
- if (obj && typeof obj === "object") {
4444
- const result: Record<string, any> = {};
4445
- for (const [k, v] of Object.entries(obj)) {
4446
- if (!k.startsWith("TypeBox.")) {
4447
- result[k] = stripTypeBoxFields(v);
4448
- }
4449
- }
4450
- return result;
4451
- }
4452
- return obj;
4453
- }
4454
-
4455
- if (tools.length > 0) {
4456
- lines.push("## Available Tools\n");
4457
- for (const tool of tools) {
4458
- lines.push(`<tool name="${tool.name}">`);
4459
- lines.push(tool.description);
4460
- const parametersClean = stripTypeBoxFields(tool.parameters);
4461
- lines.push(`\nParameters:\n${formatArgsAsXml(parametersClean as Record<string, unknown>)}`);
4462
- lines.push("<" + "/tool>\n");
4463
- }
4464
- lines.push("\n");
4465
- }
4466
-
4467
- for (const msg of this.messages) {
4468
- if (msg.role === "user") {
4469
- lines.push("## User\n");
4470
- if (typeof msg.content === "string") {
4471
- lines.push(msg.content);
4472
- } else {
4473
- for (const c of msg.content) {
4474
- if (c.type === "text") {
4475
- lines.push(c.text);
4476
- } else if (c.type === "image") {
4477
- lines.push("[Image]");
4478
- }
4479
- }
4480
- }
4481
- lines.push("\n");
4482
- } else if (msg.role === "assistant") {
4483
- const assistantMsg = msg as AssistantMessage;
4484
- lines.push("## Assistant\n");
4485
-
4486
- for (const c of assistantMsg.content) {
4487
- if (c.type === "text") {
4488
- lines.push(c.text);
4489
- } else if (c.type === "thinking") {
4490
- lines.push("<thinking>");
4491
- lines.push(c.thinking);
4492
- lines.push("</thinking>\n");
4493
- } else if (c.type === "toolCall") {
4494
- lines.push(`<invoke name="${c.name}">`);
4495
- if (c.arguments && typeof c.arguments === "object") {
4496
- lines.push(formatArgsAsXml(c.arguments as Record<string, unknown>));
4497
- }
4498
- lines.push("<" + "/invoke>\n");
4499
- }
4500
- }
4501
- lines.push("");
4502
- } else if (msg.role === "toolResult") {
4503
- lines.push(`### Tool Result: ${msg.toolName}`);
4504
- if (msg.isError) {
4505
- lines.push("(error)");
4506
- }
4507
- for (const c of msg.content) {
4508
- if (c.type === "text") {
4509
- lines.push("```");
4510
- lines.push(c.text);
4511
- lines.push("```");
4512
- } else if (c.type === "image") {
4513
- lines.push("[Image output]");
4514
- }
4515
- }
4516
- lines.push("");
4517
- } else if (msg.role === "bashExecution") {
4518
- const bashMsg = msg as BashExecutionMessage;
4519
- if (!bashMsg.excludeFromContext) {
4520
- lines.push("## Bash Execution\n");
4521
- lines.push(bashExecutionToText(bashMsg));
4522
- lines.push("\n");
4523
- }
4524
- } else if (msg.role === "pythonExecution") {
4525
- const pythonMsg = msg as PythonExecutionMessage;
4526
- if (!pythonMsg.excludeFromContext) {
4527
- lines.push("## Python Execution\n");
4528
- lines.push(pythonExecutionToText(pythonMsg));
4529
- lines.push("\n");
4530
- }
4531
- } else if (msg.role === "custom" || msg.role === "hookMessage") {
4532
- const customMsg = msg as CustomMessage | HookMessage;
4533
- lines.push(`## ${customMsg.customType}\n`);
4534
- if (typeof customMsg.content === "string") {
4535
- lines.push(customMsg.content);
4536
- } else {
4537
- for (const c of customMsg.content) {
4538
- if (c.type === "text") {
4539
- lines.push(c.text);
4540
- } else if (c.type === "image") {
4541
- lines.push("[Image]");
4542
- }
4543
- }
4544
- }
4545
- lines.push("\n");
4546
- } else if (msg.role === "branchSummary") {
4547
- const branchMsg = msg as BranchSummaryMessage;
4548
- lines.push("## Branch Summary\n");
4549
- lines.push(`(from branch: ${branchMsg.fromId})\n`);
4550
- lines.push(branchMsg.summary);
4551
- lines.push("\n");
4552
- } else if (msg.role === "compactionSummary") {
4553
- const compactMsg = msg as CompactionSummaryMessage;
4554
- lines.push("## Compaction Summary\n");
4555
- lines.push(`(${compactMsg.tokensBefore} tokens before compaction)\n`);
4556
- lines.push(compactMsg.summary);
4557
- lines.push("\n");
4558
- } else if (msg.role === "fileMention") {
4559
- const fileMsg = msg as FileMentionMessage;
4560
- lines.push("## File Mention\n");
4561
- for (const file of fileMsg.files) {
4562
- lines.push(`<file path="${file.path}">`);
4563
- if (file.content) {
4564
- lines.push(file.content);
4565
- }
4566
- if (file.image) {
4567
- lines.push("[Image attached]");
4568
- }
4569
- lines.push("</file>\n");
4570
- }
4571
- lines.push("\n");
4572
- }
4573
- }
4574
-
4575
- return lines.join("\n").trim();
3419
+ return sessionStats.formatSessionAsText(this.state);
4576
3420
  }
4577
3421
 
4578
- /**
4579
- * Format the conversation as compact context for subagents.
4580
- * Includes only user messages and assistant text responses.
4581
- * Excludes: system prompt, tool definitions, tool calls/results, thinking blocks.
4582
- */
4583
3422
  formatCompactContext(): string {
4584
- const lines: string[] = [];
4585
- lines.push("# Conversation Context");
4586
- lines.push("");
4587
- lines.push(
4588
- "This is a summary of the parent conversation. Read this if you need additional context about what was discussed or decided.",
4589
- );
4590
- lines.push("");
4591
-
4592
- for (const msg of this.messages) {
4593
- if (msg.role === "user") {
4594
- lines.push("## User");
4595
- lines.push("");
4596
- if (typeof msg.content === "string") {
4597
- lines.push(msg.content);
4598
- } else {
4599
- for (const c of msg.content) {
4600
- if (c.type === "text") {
4601
- lines.push(c.text);
4602
- } else if (c.type === "image") {
4603
- lines.push("[Image attached]");
4604
- }
4605
- }
4606
- }
4607
- lines.push("");
4608
- } else if (msg.role === "assistant") {
4609
- const assistantMsg = msg as AssistantMessage;
4610
- // Only include text content, skip tool calls and thinking
4611
- const textParts: string[] = [];
4612
- for (const c of assistantMsg.content) {
4613
- if (c.type === "text" && c.text.trim()) {
4614
- textParts.push(c.text);
4615
- }
4616
- }
4617
- if (textParts.length > 0) {
4618
- lines.push("## Assistant");
4619
- lines.push("");
4620
- lines.push(textParts.join("\n\n"));
4621
- lines.push("");
4622
- }
4623
- } else if (msg.role === "fileMention") {
4624
- const fileMsg = msg as FileMentionMessage;
4625
- const paths = fileMsg.files.map(f => f.path).join(", ");
4626
- lines.push(`[Files referenced: ${paths}]`);
4627
- lines.push("");
4628
- } else if (msg.role === "compactionSummary") {
4629
- const compactMsg = msg as CompactionSummaryMessage;
4630
- lines.push("## Earlier Context (Summarized)");
4631
- lines.push("");
4632
- lines.push(compactMsg.summary);
4633
- lines.push("");
4634
- }
4635
- // Skip: toolResult, bashExecution, pythonExecution, branchSummary, custom, hookMessage
4636
- }
4637
-
4638
- return lines.join("\n").trim();
3423
+ return sessionStats.formatCompactContext(this.messages);
4639
3424
  }
4640
3425
 
4641
3426
  // =========================================================================