@prestyj/cli 5.30.0 → 5.30.2

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 (334) hide show
  1. package/README.md +54 -39
  2. package/assets/skills/bulletproof/SKILL.md +1 -1
  3. package/assets/skills/clarify/SKILL.md +1 -1
  4. package/assets/skills/code-review/SKILL.md +1 -1
  5. package/assets/skills/compliance-guard/SKILL.md +1 -1
  6. package/assets/skills/durable/SKILL.md +1 -1
  7. package/assets/skills/evidence-led-ui/SKILL.md +1 -1
  8. package/assets/skills/lean/SKILL.md +1 -1
  9. package/assets/skills/refactoring/SKILL.md +1 -1
  10. package/assets/skills/root-cause/SKILL.md +1 -1
  11. package/assets/skills/shared-language/SKILL.md +1 -1
  12. package/assets/skills/tdd/SKILL.md +1 -1
  13. package/dist/app-sidecar/app-settings.d.ts +63 -0
  14. package/dist/app-sidecar/app-settings.js +147 -0
  15. package/dist/app-sidecar/app-settings.js.map +1 -0
  16. package/dist/app-sidecar/attachments.d.ts +11 -0
  17. package/dist/app-sidecar/attachments.js +47 -0
  18. package/dist/app-sidecar/attachments.js.map +1 -0
  19. package/dist/app-sidecar/auth-routes.d.ts +3 -0
  20. package/dist/app-sidecar/auth-routes.js +293 -0
  21. package/dist/app-sidecar/auth-routes.js.map +1 -0
  22. package/dist/app-sidecar/autopilot-runner.d.ts +62 -0
  23. package/dist/app-sidecar/autopilot-runner.js +373 -0
  24. package/dist/app-sidecar/autopilot-runner.js.map +1 -0
  25. package/dist/app-sidecar/file-search.d.ts +15 -0
  26. package/dist/app-sidecar/file-search.js +103 -0
  27. package/dist/app-sidecar/file-search.js.map +1 -0
  28. package/dist/app-sidecar/hf-pull-manager.d.ts +20 -0
  29. package/dist/app-sidecar/hf-pull-manager.js +224 -0
  30. package/dist/app-sidecar/hf-pull-manager.js.map +1 -0
  31. package/dist/app-sidecar/history-routes.d.ts +3 -0
  32. package/dist/app-sidecar/history-routes.js +375 -0
  33. package/dist/app-sidecar/history-routes.js.map +1 -0
  34. package/dist/app-sidecar/history.d.ts +67 -0
  35. package/dist/app-sidecar/history.js +23 -0
  36. package/dist/app-sidecar/history.js.map +1 -0
  37. package/dist/app-sidecar/http.d.ts +5 -0
  38. package/dist/app-sidecar/http.js +27 -0
  39. package/dist/app-sidecar/http.js.map +1 -0
  40. package/dist/app-sidecar/integration-routes.d.ts +3 -0
  41. package/dist/app-sidecar/integration-routes.js +286 -0
  42. package/dist/app-sidecar/integration-routes.js.map +1 -0
  43. package/dist/app-sidecar/json-mode.d.ts +10 -0
  44. package/dist/app-sidecar/json-mode.js +73 -0
  45. package/dist/app-sidecar/json-mode.js.map +1 -0
  46. package/dist/app-sidecar/keep-awake.d.ts +4 -0
  47. package/dist/app-sidecar/keep-awake.js +5 -0
  48. package/dist/app-sidecar/keep-awake.js.map +1 -0
  49. package/dist/app-sidecar/mcp-routes.d.ts +3 -0
  50. package/dist/app-sidecar/mcp-routes.js +185 -0
  51. package/dist/app-sidecar/mcp-routes.js.map +1 -0
  52. package/dist/app-sidecar/mcp-rows.d.ts +31 -0
  53. package/dist/app-sidecar/mcp-rows.js +77 -0
  54. package/dist/app-sidecar/mcp-rows.js.map +1 -0
  55. package/dist/app-sidecar/nolan-context.d.ts +20 -0
  56. package/dist/app-sidecar/nolan-context.js +55 -0
  57. package/dist/app-sidecar/nolan-context.js.map +1 -0
  58. package/dist/app-sidecar/progress-manager.d.ts +10 -0
  59. package/dist/app-sidecar/progress-manager.js +116 -0
  60. package/dist/app-sidecar/progress-manager.js.map +1 -0
  61. package/dist/app-sidecar/prompt-routes.d.ts +3 -0
  62. package/dist/app-sidecar/prompt-routes.js +355 -0
  63. package/dist/app-sidecar/prompt-routes.js.map +1 -0
  64. package/dist/app-sidecar/repo-polls.d.ts +18 -0
  65. package/dist/app-sidecar/repo-polls.js +45 -0
  66. package/dist/app-sidecar/repo-polls.js.map +1 -0
  67. package/dist/app-sidecar/route-context.d.ts +147 -0
  68. package/dist/app-sidecar/route-context.js +2 -0
  69. package/dist/app-sidecar/route-context.js.map +1 -0
  70. package/dist/app-sidecar/run-control-routes.d.ts +3 -0
  71. package/dist/app-sidecar/run-control-routes.js +437 -0
  72. package/dist/app-sidecar/run-control-routes.js.map +1 -0
  73. package/dist/app-sidecar/session-routes.d.ts +3 -0
  74. package/dist/app-sidecar/session-routes.js +115 -0
  75. package/dist/app-sidecar/session-routes.js.map +1 -0
  76. package/dist/app-sidecar/session-types.d.ts +39 -0
  77. package/dist/app-sidecar/session-types.js +5 -0
  78. package/dist/app-sidecar/session-types.js.map +1 -0
  79. package/dist/app-sidecar/task-routes.d.ts +3 -0
  80. package/dist/app-sidecar/task-routes.js +232 -0
  81. package/dist/app-sidecar/task-routes.js.map +1 -0
  82. package/dist/app-sidecar/usage.d.ts +19 -0
  83. package/dist/app-sidecar/usage.js +153 -0
  84. package/dist/app-sidecar/usage.js.map +1 -0
  85. package/dist/app-sidecar/workspace-routes.d.ts +3 -0
  86. package/dist/app-sidecar/workspace-routes.js +374 -0
  87. package/dist/app-sidecar/workspace-routes.js.map +1 -0
  88. package/dist/app-sidecar.js +541 -3971
  89. package/dist/app-sidecar.js.map +1 -1
  90. package/dist/chat-agents/jiwa.js +2 -0
  91. package/dist/chat-agents/jiwa.js.map +1 -1
  92. package/dist/chat-agents/memory.js +2 -0
  93. package/dist/chat-agents/memory.js.map +1 -1
  94. package/dist/cli/auth.js +13 -9
  95. package/dist/cli/auth.js.map +1 -1
  96. package/dist/cli.js +10 -11
  97. package/dist/cli.js.map +1 -1
  98. package/dist/config.js +1 -1
  99. package/dist/core/acceptance-checks.js +3 -9
  100. package/dist/core/acceptance-checks.js.map +1 -1
  101. package/dist/core/agent-session/helpers.d.ts +26 -0
  102. package/dist/core/agent-session/helpers.js +47 -0
  103. package/dist/core/agent-session/helpers.js.map +1 -0
  104. package/dist/core/agent-session/mcp-tools.d.ts +68 -0
  105. package/dist/core/agent-session/mcp-tools.js +195 -0
  106. package/dist/core/agent-session/mcp-tools.js.map +1 -0
  107. package/dist/core/agent-session/run-loop-monitor.d.ts +63 -0
  108. package/dist/core/agent-session/run-loop-monitor.js +164 -0
  109. package/dist/core/agent-session/run-loop-monitor.js.map +1 -0
  110. package/dist/core/agent-session/transcript-markers.d.ts +57 -0
  111. package/dist/core/agent-session/transcript-markers.js +166 -0
  112. package/dist/core/agent-session/transcript-markers.js.map +1 -0
  113. package/dist/core/agent-session/verification-tracker.d.ts +50 -0
  114. package/dist/core/agent-session/verification-tracker.js +269 -0
  115. package/dist/core/agent-session/verification-tracker.js.map +1 -0
  116. package/dist/core/agent-session.d.ts +27 -87
  117. package/dist/core/agent-session.js +166 -713
  118. package/dist/core/agent-session.js.map +1 -1
  119. package/dist/core/auth-providers.js +3 -3
  120. package/dist/core/auth-providers.js.map +1 -1
  121. package/dist/core/autopilot-cycle.d.ts +8 -2
  122. package/dist/core/autopilot-cycle.js +22 -3
  123. package/dist/core/autopilot-cycle.js.map +1 -1
  124. package/dist/core/bundled-agents.js +6 -6
  125. package/dist/core/bundled-agents.js.map +1 -1
  126. package/dist/core/checklist-git.d.ts +19 -0
  127. package/dist/core/checklist-git.js +54 -0
  128. package/dist/core/checklist-git.js.map +1 -0
  129. package/dist/core/checklist-items.d.ts +10 -1
  130. package/dist/core/checklist-items.js +22 -0
  131. package/dist/core/checklist-items.js.map +1 -1
  132. package/dist/core/checklist-prompt.d.ts +14 -3
  133. package/dist/core/checklist-prompt.js +38 -8
  134. package/dist/core/checklist-prompt.js.map +1 -1
  135. package/dist/core/checklist-snapshot.d.ts +5 -1
  136. package/dist/core/checklist-snapshot.js +20 -3
  137. package/dist/core/checklist-snapshot.js.map +1 -1
  138. package/dist/core/checklist-store.d.ts +5 -1
  139. package/dist/core/checklist-store.js +35 -4
  140. package/dist/core/checklist-store.js.map +1 -1
  141. package/dist/core/context-limits.js +1 -1
  142. package/dist/core/context-limits.js.map +1 -1
  143. package/dist/core/event-bus.d.ts +1 -1
  144. package/dist/core/event-bus.js +39 -13
  145. package/dist/core/event-bus.js.map +1 -1
  146. package/dist/core/extensions/plugin-bundles.js +4 -2
  147. package/dist/core/extensions/plugin-bundles.js.map +1 -1
  148. package/dist/core/hashline.d.ts +10 -8
  149. package/dist/core/hashline.js.map +1 -1
  150. package/dist/core/mcp/client.js +2 -0
  151. package/dist/core/mcp/client.js.map +1 -1
  152. package/dist/core/mcp/loopback.d.ts +2 -2
  153. package/dist/core/mcp/loopback.js +2 -2
  154. package/dist/core/mcp/shared-pool.js +4 -3
  155. package/dist/core/mcp/shared-pool.js.map +1 -1
  156. package/dist/core/persistent-shell.js +2 -1
  157. package/dist/core/persistent-shell.js.map +1 -1
  158. package/dist/core/plan-handoff.d.ts +30 -0
  159. package/dist/core/plan-handoff.js +10 -0
  160. package/dist/core/plan-handoff.js.map +1 -0
  161. package/dist/core/plan-submission-gate.d.ts +28 -0
  162. package/dist/core/plan-submission-gate.js +27 -0
  163. package/dist/core/plan-submission-gate.js.map +1 -0
  164. package/dist/core/process-manager.js +9 -1
  165. package/dist/core/process-manager.js.map +1 -1
  166. package/dist/core/project-health-scan.d.ts +109 -0
  167. package/dist/core/project-health-scan.js +673 -0
  168. package/dist/core/project-health-scan.js.map +1 -0
  169. package/dist/core/project-health-score.d.ts +41 -0
  170. package/dist/core/project-health-score.js +257 -0
  171. package/dist/core/project-health-score.js.map +1 -0
  172. package/dist/core/prompt-commands.js +44 -6
  173. package/dist/core/prompt-commands.js.map +1 -1
  174. package/dist/core/session-manager.js.map +1 -1
  175. package/dist/core/session-storage.d.ts +1 -0
  176. package/dist/core/session-storage.js +1 -1
  177. package/dist/core/session-storage.js.map +1 -1
  178. package/dist/core/skills.js +6 -9
  179. package/dist/core/skills.js.map +1 -1
  180. package/dist/core/style-packs/index.js +2 -11
  181. package/dist/core/style-packs/index.js.map +1 -1
  182. package/dist/core/style-packs/packs.d.ts +3 -12
  183. package/dist/core/style-packs/packs.js +132 -173
  184. package/dist/core/style-packs/packs.js.map +1 -1
  185. package/dist/core/subagent-manager.js +11 -6
  186. package/dist/core/subagent-manager.js.map +1 -1
  187. package/dist/core/subagent-receipt.js +3 -4
  188. package/dist/core/subagent-receipt.js.map +1 -1
  189. package/dist/core/ui-adoption.js +2 -0
  190. package/dist/core/ui-adoption.js.map +1 -1
  191. package/dist/core/ui-registry.js +5 -4
  192. package/dist/core/ui-registry.js.map +1 -1
  193. package/dist/core/verification-evidence.d.ts +21 -2
  194. package/dist/core/verification-evidence.js +283 -8
  195. package/dist/core/verification-evidence.js.map +1 -1
  196. package/dist/core/verification-gate.js +4 -2
  197. package/dist/core/verification-gate.js.map +1 -1
  198. package/dist/core/verify-commands.js +2 -3
  199. package/dist/core/verify-commands.js.map +1 -1
  200. package/dist/modes/agent-home-mode.js +1 -4
  201. package/dist/modes/agent-home-mode.js.map +1 -1
  202. package/dist/modes/subagent-worker-mode.js +13 -8
  203. package/dist/modes/subagent-worker-mode.js.map +1 -1
  204. package/dist/system-prompt.js +70 -87
  205. package/dist/system-prompt.js.map +1 -1
  206. package/dist/tools/ask-user.js +13 -74
  207. package/dist/tools/ask-user.js.map +1 -1
  208. package/dist/tools/bash.d.ts +3 -1
  209. package/dist/tools/bash.js +28 -84
  210. package/dist/tools/bash.js.map +1 -1
  211. package/dist/tools/checklist.d.ts +7 -14
  212. package/dist/tools/checklist.js +22 -41
  213. package/dist/tools/checklist.js.map +1 -1
  214. package/dist/tools/code-nav.js +7 -29
  215. package/dist/tools/code-nav.js.map +1 -1
  216. package/dist/tools/edit.d.ts +4 -9
  217. package/dist/tools/edit.js +175 -65
  218. package/dist/tools/edit.js.map +1 -1
  219. package/dist/tools/enter-plan.js +2 -7
  220. package/dist/tools/enter-plan.js.map +1 -1
  221. package/dist/tools/exit-plan.js +2 -3
  222. package/dist/tools/exit-plan.js.map +1 -1
  223. package/dist/tools/find.js +3 -4
  224. package/dist/tools/find.js.map +1 -1
  225. package/dist/tools/grep.js +9 -20
  226. package/dist/tools/grep.js.map +1 -1
  227. package/dist/tools/index.js +10 -7
  228. package/dist/tools/index.js.map +1 -1
  229. package/dist/tools/ls.js +3 -3
  230. package/dist/tools/ls.js.map +1 -1
  231. package/dist/tools/prompt-hints.js +34 -47
  232. package/dist/tools/prompt-hints.js.map +1 -1
  233. package/dist/tools/read-only-bash.js +3 -0
  234. package/dist/tools/read-only-bash.js.map +1 -1
  235. package/dist/tools/read.js +8 -20
  236. package/dist/tools/read.js.map +1 -1
  237. package/dist/tools/search-code.js +4 -12
  238. package/dist/tools/search-code.js.map +1 -1
  239. package/dist/tools/skill.js +13 -7
  240. package/dist/tools/skill.js.map +1 -1
  241. package/dist/tools/subagent-control.js +7 -22
  242. package/dist/tools/subagent-control.js.map +1 -1
  243. package/dist/tools/subagent.js +1 -1
  244. package/dist/tools/subagent.js.map +1 -1
  245. package/dist/tools/task-output.js +4 -15
  246. package/dist/tools/task-output.js.map +1 -1
  247. package/dist/tools/task-send.js +5 -17
  248. package/dist/tools/task-send.js.map +1 -1
  249. package/dist/tools/task-stop.js +2 -2
  250. package/dist/tools/task-stop.js.map +1 -1
  251. package/dist/tools/tool-search.js +2 -7
  252. package/dist/tools/tool-search.js.map +1 -1
  253. package/dist/tools/tool-tiers.d.ts +21 -4
  254. package/dist/tools/tool-tiers.js +44 -17
  255. package/dist/tools/tool-tiers.js.map +1 -1
  256. package/dist/tools/web-fetch.js +7 -33
  257. package/dist/tools/web-fetch.js.map +1 -1
  258. package/dist/tools/write.js +3 -4
  259. package/dist/tools/write.js.map +1 -1
  260. package/dist/ui/App.js +20 -26
  261. package/dist/ui/App.js.map +1 -1
  262. package/dist/ui/components/Footer.js +1 -2
  263. package/dist/ui/components/Footer.js.map +1 -1
  264. package/dist/ui/components/InputArea.js +0 -2
  265. package/dist/ui/components/InputArea.js.map +1 -1
  266. package/dist/ui/components/ToolExecution.js +4 -3
  267. package/dist/ui/components/ToolExecution.js.map +1 -1
  268. package/dist/ui/hooks/useAgentLoop.js +7 -6
  269. package/dist/ui/hooks/useAgentLoop.js.map +1 -1
  270. package/dist/ui/sessions.js +1 -5
  271. package/dist/ui/sessions.js.map +1 -1
  272. package/dist/ui/utils/highlight.d.ts +0 -5
  273. package/dist/ui/utils/highlight.js +3 -49
  274. package/dist/ui/utils/highlight.js.map +1 -1
  275. package/dist/ui/utils/markdown-renderer.d.ts +6 -0
  276. package/dist/ui/utils/markdown-renderer.js +21 -5
  277. package/dist/ui/utils/markdown-renderer.js.map +1 -1
  278. package/dist/utils/process.js +3 -3
  279. package/dist/utils/process.js.map +1 -1
  280. package/dist/utils/shared-poll.d.ts +37 -0
  281. package/dist/utils/shared-poll.js +104 -0
  282. package/dist/utils/shared-poll.js.map +1 -0
  283. package/package.json +8 -8
  284. package/dist/core/api-benchmark.d.ts +0 -63
  285. package/dist/core/api-benchmark.js +0 -381
  286. package/dist/core/api-benchmark.js.map +0 -1
  287. package/dist/core/bash-spawn-benchmark.d.ts +0 -1
  288. package/dist/core/bash-spawn-benchmark.js +0 -139
  289. package/dist/core/bash-spawn-benchmark.js.map +0 -1
  290. package/dist/core/cache-warm-benchmark.d.ts +0 -21
  291. package/dist/core/cache-warm-benchmark.js +0 -170
  292. package/dist/core/cache-warm-benchmark.js.map +0 -1
  293. package/dist/core/fast-apply-benchmark.d.ts +0 -25
  294. package/dist/core/fast-apply-benchmark.js +0 -284
  295. package/dist/core/fast-apply-benchmark.js.map +0 -1
  296. package/dist/core/hashline-edit-benchmark.d.ts +0 -88
  297. package/dist/core/hashline-edit-benchmark.js +0 -518
  298. package/dist/core/hashline-edit-benchmark.js.map +0 -1
  299. package/dist/core/semantic-search-benchmark.d.ts +0 -36
  300. package/dist/core/semantic-search-benchmark.js +0 -211
  301. package/dist/core/semantic-search-benchmark.js.map +0 -1
  302. package/dist/core/speed-benchmark.d.ts +0 -132
  303. package/dist/core/speed-benchmark.js +0 -410
  304. package/dist/core/speed-benchmark.js.map +0 -1
  305. package/dist/ui/activity-phrases.d.ts +0 -10
  306. package/dist/ui/activity-phrases.js +0 -184
  307. package/dist/ui/activity-phrases.js.map +0 -1
  308. package/dist/ui/components/PlanApproval.d.ts +0 -7
  309. package/dist/ui/components/PlanApproval.js +0 -60
  310. package/dist/ui/components/PlanApproval.js.map +0 -1
  311. package/dist/ui/components/PlanBanner.d.ts +0 -5
  312. package/dist/ui/components/PlanBanner.js +0 -31
  313. package/dist/ui/components/PlanBanner.js.map +0 -1
  314. package/dist/ui/components/PlanProgress.d.ts +0 -6
  315. package/dist/ui/components/PlanProgress.js +0 -30
  316. package/dist/ui/components/PlanProgress.js.map +0 -1
  317. package/dist/ui/hooks/useMinDisplayTime.d.ts +0 -10
  318. package/dist/ui/hooks/useMinDisplayTime.js +0 -34
  319. package/dist/ui/hooks/useMinDisplayTime.js.map +0 -1
  320. package/dist/ui/hooks/useSessionManager.d.ts +0 -12
  321. package/dist/ui/hooks/useSessionManager.js +0 -43
  322. package/dist/ui/hooks/useSessionManager.js.map +0 -1
  323. package/dist/ui/hooks/useSlashCommands.d.ts +0 -6
  324. package/dist/ui/hooks/useSlashCommands.js +0 -11
  325. package/dist/ui/hooks/useSlashCommands.js.map +0 -1
  326. package/dist/ui/terminal-history-spacing.d.ts +0 -1
  327. package/dist/ui/terminal-history-spacing.js +0 -2
  328. package/dist/ui/terminal-history-spacing.js.map +0 -1
  329. package/dist/ui/utils/markdown-cache.d.ts +0 -10
  330. package/dist/ui/utils/markdown-cache.js +0 -58
  331. package/dist/ui/utils/markdown-cache.js.map +0 -1
  332. package/dist/ui/utils/token-to-ansi.d.ts +0 -8
  333. package/dist/ui/utils/token-to-ansi.js +0 -323
  334. package/dist/ui/utils/token-to-ansi.js.map +0 -1
@@ -13,31 +13,27 @@ import { dualAuthProvider } from "@prestyj/core";
13
13
  import { getClaudeCliUserAgent, recordRequiredClaudeCodeVersion } from "./claude-code-version.js";
14
14
  import { kimiCodingHeaders, isKimiCodingEndpoint } from "./oauth/kimi.js";
15
15
  import { isGrokCliEndpoint } from "./oauth/xai.js";
16
- import { SessionManager, NOLAN_TURN_CUSTOM_KIND, AUTOPILOT_MARKER_CUSTOM_KIND, APP_MARKER_CUSTOM_KIND, } from "./session-manager.js";
16
+ import { SessionManager, } from "./session-manager.js";
17
17
  import { ExtensionLoader } from "./extensions/loader.js";
18
18
  import { shouldCompact, compact, } from "./compaction/compactor.js";
19
- import { getHistoryMessageVisibility, remapAnchorForCompaction, stripRecordedPosition, } from "./session-history.js";
19
+ import { getHistoryMessageVisibility } from "./session-history.js";
20
20
  import { sourceFingerprint as computeSourceFingerprint } from "./session-compaction.js";
21
- import { getAuthStorageKeys, getContextWindow, getModel, getToolResultCharLimit, MODELS, } from "./model-registry.js";
21
+ import { getAuthStorageKeys, getContextWindow, getModel, MODELS } from "./model-registry.js";
22
22
  import { discoverSkills } from "./skills.js";
23
23
  import { ensureAppDirs } from "../config.js";
24
24
  import { buildSubAgentSystemPrompt, buildSystemPrompt, } from "../system-prompt.js";
25
25
  import { createTools, createWebSearchTool, } from "../tools/index.js";
26
26
  import { partitionToolsByTier } from "../tools/tool-tiers.js";
27
27
  import { formatImpactForVerification } from "./test-impact.js";
28
- import { autoBackgroundedId, REVIEW_REJECTED_BEFORE_START } from "../tools/bash.js";
29
28
  import { buildProcessCompletionFollowUp } from "./process-gate.js";
29
+ import { PLAN_SUBMISSION_NUDGE, shouldNudgePlanSubmission } from "./plan-submission-gate.js";
30
30
  import { buildSubAgentCompletionFollowUp } from "./subagent-manager.js";
31
31
  import { applyAsyncSubagentPolicy } from "./subagent-policy.js";
32
32
  import { resolveResponsesLite, resolveStrictTools, } from "./codex-request-shape.js";
33
33
  import { subAgentDescription } from "../tools/subagent.js";
34
- import { z } from "zod";
35
- import { MCPClientManager, getAllMcpServers } from "./mcp/index.js";
36
- import { clampMcpToolDescription, DeferredToolCatalog } from "./mcp/deferred-catalog.js";
34
+ import { getAllMcpServers } from "./mcp/index.js";
37
35
  import { CONTEXT_LIMITS, resolveSessionContextLimits, } from "./context-limits.js";
38
- import { McpCatalogCache } from "./mcp/catalog-cache.js";
39
36
  import { describeDropped, importForeignSession, } from "./foreign-session-import.js";
40
- import { createToolSearchTool } from "../tools/tool-search.js";
41
37
  import { createSessionStatsTool } from "../tools/session-stats.js";
42
38
  import { createDiagnoseCommand, isInternalDiagnosticsEnabled, SessionDiagnosticsRecorder, } from "./internal-diagnostics.js";
43
39
  import { log } from "./logger.js";
@@ -54,15 +50,13 @@ import { pruneStaleToolResults } from "./compaction/tool-result-pruner.js";
54
50
  import { discoverAgents } from "./agents.js";
55
51
  import { enhancePrompt } from "../utils/prompt-enhancer.js";
56
52
  import { detectLanguages, detectProjectStack } from "./language-detector.js";
57
- import { evaluateLoopBreak, buildLoopBreakMessage, CycleDetector, ToolCallProgressTracker, detectTextRepetition, } from "./loop-breaker.js";
53
+ import { buildLoopBreakMessage } from "./loop-breaker.js";
58
54
  import { buildRegroundingMessage, requestTextForRegrounding } from "./regrounding.js";
59
- import { buildSemanticLoopJudgePrompt, buildSemanticLoopMessage, MAX_SEMANTIC_LOOP_CALLS, parseSemanticLoopVerdict, shouldRunSemanticLoopCheck, SEMANTIC_LOOP_JUDGE_TIMEOUT_MS, withJudgeTimeout, } from "./semantic-loop-check.js";
55
+ import { buildSemanticLoopJudgePrompt, buildSemanticLoopMessage, parseSemanticLoopVerdict, SEMANTIC_LOOP_JUDGE_TIMEOUT_MS, withJudgeTimeout, } from "./semantic-loop-check.js";
60
56
  import { buildEnvDeltaMessage } from "./env-delta.js";
61
57
  import { wrapSteeringText, buildNotificationSteeringText, STEERING_PREFIX } from "./steering.js";
62
58
  import { AgentNotificationQueue } from "./agent-notifications.js";
63
- import { VerificationGate, isCheckOwnFile, extractAddedLines, isCodeFilePath, VERIFICATION_STATE_KIND, isVerificationCommand, } from "./verification-gate.js";
64
- import { classifyVerificationCommand } from "./verification-evidence.js";
65
- import { captureVerificationSnapshot } from "./verification-snapshot.js";
59
+ import { VerificationGate, VERIFICATION_STATE_KIND } from "./verification-gate.js";
66
60
  import { findUserSessionPrompt, getUserSessionPrompt } from "./session-preview.js";
67
61
  import { normalizeMessageImages } from "./message-images.js";
68
62
  import { loadStreamRules } from "./stream-rules.js";
@@ -70,41 +64,12 @@ import crypto from "node:crypto";
70
64
  import fs from "node:fs/promises";
71
65
  import os from "node:os";
72
66
  import path from "node:path";
73
- import { editTargetPaths } from "../tools/edit-targets.js";
74
- /**
75
- * A run whose tool calls fail more often than this is thrashing, not
76
- * progressing — refuse to extend its turn budget.
77
- */
78
- const TURN_EXTENSION_MAX_FAILURE_RATIO = 0.5;
79
- // ── Tool-result policy ─────────────────────────────────────
80
- /** Resolve the per-result cap passed to the agent loop for the active transport. */
81
- export function resolveSessionToolResultCharLimit(model, provider, accountId) {
82
- return (getToolResultCharLimit(model, { provider, accountId }) ??
83
- Math.floor(getContextWindow(model, { provider, accountId }) * 3.5 * 0.3));
84
- }
85
- /**
86
- * Aggregate budget for ALL tool results produced in one assistant turn.
87
- * Individual results are already capped, but wide parallel fan-outs (GPT-5.6's
88
- * signature behavior) were observed injecting 100k+ uncached tokens in a single
89
- * turn. ~15% of the context window in chars (1 token ≈ 3.5 chars), floored at
90
- * 100KB so small windows still fit two full-size reads, ceilinged at 240KB so
91
- * 1M-context models don't waive the budget entirely.
92
- */
93
- export function resolveSessionTurnToolResultCharLimit(model, provider, accountId) {
94
- const contextChars = getContextWindow(model, { provider, accountId }) * 3.5;
95
- return Math.max(100_000, Math.min(Math.floor(contextChars * 0.15), 240_000));
96
- }
97
- /** Marker the compactor prepends to the summary message it injects. */
98
- /**
99
- * True when an assistant message ends a turn with tool calls still awaiting
100
- * their results. Inserting a user message there would orphan the tool_use
101
- * blocks and the provider rejects the next request.
102
- */
103
- function hasUnresolvedToolCalls(message) {
104
- if (typeof message.content === "string" || !Array.isArray(message.content))
105
- return false;
106
- return message.content.some((part) => part.type === "tool_call");
107
- }
67
+ import { resolveSessionToolResultCharLimit, resolveSessionTurnToolResultCharLimit, hasUnresolvedToolCalls, resolveHomePath, } from "./agent-session/helpers.js";
68
+ import { RunLoopMonitor } from "./agent-session/run-loop-monitor.js";
69
+ import { VerificationTracker } from "./agent-session/verification-tracker.js";
70
+ import { McpToolRegistry } from "./agent-session/mcp-tools.js";
71
+ import { TranscriptMarkers } from "./agent-session/transcript-markers.js";
72
+ export { resolveSessionToolResultCharLimit, resolveSessionTurnToolResultCharLimit, } from "./agent-session/helpers.js";
108
73
  // ── Agent Session ──────────────────────────────────────────
109
74
  export class AgentSession {
110
75
  eventBus = new EventBus();
@@ -114,24 +79,10 @@ export class AgentSession {
114
79
  sessionManager;
115
80
  extensionLoader = new ExtensionLoader();
116
81
  messages = [];
117
- // Nolan Grout (mentor agent) turns recorded against this build session. Advisory
118
- // only — NEVER part of `messages` (EZ Coder must not see them), but persisted
119
- // alongside the session and reloaded on resume so they reappear in the
120
- // transcript. Each carries the non-system message count at record time so the
121
- // webview can interleave them chronologically.
122
- nolanTurns = [];
123
- // Autopilot Nolan (auto-reviewer) markers recorded against this build session:
124
- // the review verdict shown in the transcript (prompted / done / human /
125
- // capped). Same not-on-the-DAG treatment as nolanTurns — advisory only,
126
- // persisted + reloaded so a resumed session shows the identical Nolan bubble
127
- // the live run showed instead of dropping it or replaying a raw verdict.
128
- autopilotMarkers = [];
129
- // Generic app transcript markers (plan-mode banner, task header, error rows,
130
- // user-bubble display hints). Same not-on-the-DAG treatment as nolanTurns —
131
- // display only, persisted + reloaded so a resumed session shows the same
132
- // transcript rows the live run showed.
133
- appMarkers = [];
134
- turnMetrics = [];
82
+ // Display-only transcript history (Nolan turns, autopilot verdicts, app markers,
83
+ // turn metrics). NEVER part of `messages` (EZ Coder must not see them), but
84
+ // persisted alongside the session and reloaded on resume.
85
+ markers = new TranscriptMarkers(() => this.sessionManager, () => this.sessionPath);
135
86
  cacheDiagnostics = new CacheDiagnostics();
136
87
  /** Internal-only (EZ_INTERNAL): live per-session cost/reliability recorder.
137
88
  * Absent entirely in public builds — see core/internal-diagnostics.ts. */
@@ -150,25 +101,8 @@ export class AgentSession {
150
101
  // ── Self-correction hook state (mirrors the TUI's useAgentLoop refs) ──
151
102
  // Reset at the start of every run; observed from the event stream; read by
152
103
  // the loop-break (mid-loop) callback.
153
- hookStats = { toolCalls: 0, toolFailures: 0, turns: 0 };
154
- hookText = "";
155
- hookConsecutiveFailures = 0;
156
- hookRepeatedNoProgressCalls = 0;
157
- hookProgressTracker = new ToolCallProgressTracker();
158
- hookCycleDetector = new CycleDetector();
159
- hookCyclicPattern = null;
160
- hookFileEditCounts = new Map();
161
- hookToolCalls = new Map();
162
- backgroundVerification = new Map();
163
- /** 0 = none; 1 = first nudge sent; 2 = final stop-and-report injected. */
164
- loopBreakInjected = 0;
104
+ loopMonitor = new RunLoopMonitor();
165
105
  regroundingInjected = false;
166
- /** Recent tool-call digests for the semantic loop judge — bounded ring. */
167
- hookRecentCalls = [];
168
- /** LLM-judged loop detection state. `verdict` holds a LOOP verdict awaiting
169
- * injection at the next steering poll; judge failures fail open (no
170
- * injection) and still consume budget + cooldown. */
171
- semanticLoop = { checksUsed: 0, lastCheckTurn: 0, pending: false, verdict: null, injected: false };
172
106
  /**
173
107
  * The environment as the cached system prompt currently describes it.
174
108
  * Re-recorded on every prompt build, so a rebuild (e.g. `/add-dir`) needs no
@@ -179,10 +113,14 @@ export class AgentSession {
179
113
  runStartedAt = 0;
180
114
  /** Gate injections spent this run, capped by MAX_PROCESS_GATE_INJECTIONS. */
181
115
  processGateInjected = 0;
116
+ /** Plan-submission reminders spent this run (plan-submission-gate.ts). */
117
+ planSubmissionNudges = 0;
182
118
  /** Verification gate: code edited this run, nothing proved it since. Always
183
119
  * tracks evidence for run status; the `verificationGateEnabled` setting
184
120
  * decides whether an unverified stop is also continued once. */
185
121
  verificationGate = new VerificationGate();
122
+ /** Host-observed evidence (edited files, in-flight and background checks) feeding the gate. */
123
+ verificationTracker;
186
124
  /** Mirror of the last `hook_armed` value, so the event fires only on an edge. */
187
125
  preFinalArmed = false;
188
126
  compactionOccurred = false;
@@ -249,9 +187,8 @@ export class AgentSession {
249
187
  this.lspManager?.clearPendingDiagnostics();
250
188
  void this.subAgentManager?.interruptAll();
251
189
  };
252
- mcpManager;
253
- /** Deferred MCP tools awaiting discovery via tool_search. */
254
- mcpCatalog;
190
+ /** MCP client, deferred tool_search catalog and cached-stub reconcile state. */
191
+ mcp = new McpToolRegistry(() => this.tools, () => this.contextLimits);
255
192
  /** Resolved prompt-injection byte budgets (contextLimits setting). */
256
193
  contextLimits = CONTEXT_LIMITS;
257
194
  /**
@@ -260,11 +197,6 @@ export class AgentSession {
260
197
  * can discover and promote them; a promoted name drops out of this list.
261
198
  */
262
199
  deferredBuiltinToolNames = [];
263
- /** Live (connected) MCP tools by name — the reconcile target for cached stubs. */
264
- liveMcpTools = new Map();
265
- /** Server name for each cached-only tool, so a stub knows what to wait on. */
266
- cachedMcpToolServers = new Map();
267
- mcpCatalogCache = new McpCatalogCache();
268
200
  provider;
269
201
  model;
270
202
  cwd;
@@ -337,6 +269,7 @@ export class AgentSession {
337
269
  opts;
338
270
  constructor(options) {
339
271
  this.opts = options;
272
+ this.verificationTracker = new VerificationTracker(this.verificationGate, options.cwd, () => this.processManager?.list() ?? []);
340
273
  this.provider = options.provider;
341
274
  this.model = options.model;
342
275
  this.cwd = options.cwd;
@@ -438,7 +371,7 @@ export class AgentSession {
438
371
  authStorage: this.authStorage,
439
372
  onFileMutated: (filePath) => {
440
373
  const relative = path.relative(this.cwd, filePath) || path.basename(filePath);
441
- this.hookFileEditCounts.set(relative, (this.hookFileEditCounts.get(relative) ?? 0) + 1);
374
+ this.verificationTracker.recordFileMutated(relative);
442
375
  },
443
376
  // Lazy — sessionId/model/provider can change after createTools() runs, so
444
377
  // sub-agent spawns read the current parent state at execution time.
@@ -485,6 +418,10 @@ export class AgentSession {
485
418
  // every request. Allow-listed sessions keep the eager path — their fixed
486
419
  // tool expectations predate the catalog, and tool_search isn't allow-listed.
487
420
  if (!this.opts.allowedTools && this.settingsManager.get("deferredBuiltinTools")) {
421
+ // Wrap before partitioning: spawn_agent itself may be deferred, and the
422
+ // catalog must hold the wrapped tool so a promoted spawn still loads
423
+ // wait_agent.
424
+ this.promoteFollowUpTools();
488
425
  const { core, deferred } = partitionToolsByTier(this.tools);
489
426
  if (deferred.length > 0) {
490
427
  // Append-only: `core` preserves the original relative order and
@@ -492,10 +429,7 @@ export class AgentSession {
492
429
  // sits inside the cached prefix stays byte-stable across turns.
493
430
  this.tools = core;
494
431
  this.deferredBuiltinToolNames = deferred.map((t) => t.name);
495
- this.mcpCatalog ??= new DeferredToolCatalog(this.contextLimits);
496
- this.mcpCatalog.add(deferred);
497
- this.ensureToolSearchTool();
498
- this.promoteWaitAgentAfterSpawn();
432
+ this.mcp.addDeferred(deferred);
499
433
  }
500
434
  }
501
435
  this.rebuildReadTool = rebuildReadTool;
@@ -510,8 +444,7 @@ export class AgentSession {
510
444
  // Connect MCP servers. Child sessions skip user-configured servers to avoid
511
445
  // cold-spawning every server per short-lived worker. Background connection
512
446
  // keeps slow first-run stdio servers off the host readiness path.
513
- this.mcpManager = new MCPClientManager({
514
- catalogCache: this.mcpCatalogCache,
447
+ this.mcp.createManager({
515
448
  modernProtocol: this.settingsManager.get("mcpModernProtocol"),
516
449
  onElicit: this.opts.onMcpElicit,
517
450
  });
@@ -660,7 +593,8 @@ export class AgentSession {
660
593
  * turn, so background-connected servers become available on the next prompt.
661
594
  */
662
595
  async connectMcpServers() {
663
- if (!this.mcpManager)
596
+ const mcpManager = this.mcp.manager;
597
+ if (!mcpManager)
664
598
  return;
665
599
  // Allow-listed (read-only advisory) sessions enforce a fixed tool set by
666
600
  // name. An MCP server is only connected when its name is explicitly
@@ -696,7 +630,7 @@ export class AgentSession {
696
630
  // empty catalog and tool_search would answer "the catalog is empty" for
697
631
  // capabilities that genuinely exist — a wrong answer, not a slow one.
698
632
  await this.seedMcpCatalogFromCache(servers);
699
- const connected = await this.mcpManager.connectAll(servers);
633
+ const connected = await mcpManager.connectAll(servers);
700
634
  // Defense-in-depth: even from a whitelisted server, only push tools that
701
635
  // pass the allow-list (no-op when there's no allow-list).
702
636
  const mcpTools = this.opts.allowedTools
@@ -735,170 +669,65 @@ export class AgentSession {
735
669
  * pushed eagerly when the user opted out.
736
670
  * Allow-listed sessions (Nolan) always get the eager path — their fixed tool
737
671
  * expectations predate the catalog, and tool_search isn't allow-listed.
738
- * Promotion pushes onto the live `this.tools` array the running agent loop
739
- * re-reads every turn, so promoted tools are callable on the next step.
740
672
  */
741
673
  addMcpTools(mcpTools) {
742
- if (mcpTools.length === 0)
743
- return;
744
- for (const tool of mcpTools) {
745
- this.liveMcpTools.set(tool.name, tool);
746
- this.cachedMcpToolServers.delete(tool.name);
747
- }
748
674
  const defer = !this.opts.allowedTools && this.settingsManager.get("deferredMcpTools");
749
- if (!defer) {
750
- // Eager path bypasses the catalog, so budget descriptions here too.
751
- this.replaceOrPushTools(mcpTools.map((tool) => clampMcpToolDescription(tool, this.contextLimits)));
752
- return;
753
- }
754
- this.mcpCatalog ??= new DeferredToolCatalog(this.contextLimits);
755
- // `add` is name-keyed, so live definitions replace cached stubs in place.
756
- this.mcpCatalog.add(mcpTools);
757
- // A stub the model already promoted lives in `this.tools`; swap it for the
758
- // live tool so later calls dispatch directly instead of through the stub.
759
- this.replaceLivePromotedTools(mcpTools);
760
- this.ensureToolSearchTool();
675
+ this.mcp.addMcpTools(mcpTools, defer);
761
676
  }
762
677
  /**
763
- * Register `tool_search` once. Promotion of a cached-only entry waits for its
764
- * server so the model is told immediately when that capability turns out to
765
- * be unreachable, instead of promoting a tool that fails on first call.
766
- *
767
- * The catalog is created on demand rather than required up front: deferred
768
- * built-in tools populate it with zero MCP servers connected, so gating
769
- * registration on an existing catalog would leave those tools unreachable.
678
+ * Deferred follow-up tools whose need is certain once a trigger succeeds.
679
+ * `wait_agent` follows nearly every `spawn_agent`, and loading it through
680
+ * `tool_search` cost a whole turn (bench 41: ~6 s per fan-out). Likewise a
681
+ * background `bash` is always followed by `task_output` (and sometimes
682
+ * `task_send`/`task_stop`). Promote them as soon as the trigger succeeds: the
683
+ * tool list grows exactly as it would after that `tool_search`, one turn
684
+ * earlier, and sessions that never trigger keep the smaller prefix.
685
+ * Call before tier partitioning so a deferred trigger is wrapped too.
770
686
  */
771
- ensureToolSearchTool() {
772
- this.mcpCatalog ??= new DeferredToolCatalog(this.contextLimits);
773
- if (this.tools.some((t) => t.name === "tool_search"))
774
- return;
775
- this.tools.push(createToolSearchTool(this.mcpCatalog, (promoted) => {
776
- this.tools.push(...promoted);
777
- }, async (toolName) => {
778
- if (this.liveMcpTools.has(toolName))
779
- return undefined;
780
- const serverName = this.cachedMcpToolServers.get(toolName);
781
- if (!serverName)
782
- return undefined;
783
- const outcome = (await this.mcpManager?.whenConnected(serverName)) ?? {
784
- ok: false,
785
- error: "MCP is disabled for this session",
786
- };
787
- return outcome.ok
788
- ? { serverName, ok: true }
789
- : { serverName, ok: false, error: outcome.error };
790
- }, this.contextLimits));
687
+ promoteFollowUpTools() {
688
+ this.wrapToPromote("spawn_agent", ["wait_agent"]);
689
+ this.wrapToPromote("bash", ["task_output", "task_send", "task_stop"], (args) => args.run_in_background === true);
791
690
  }
792
- /**
793
- * `wait_agent` is deferred, yet nearly every `spawn_agent` is followed by it,
794
- * so the model spent a whole turn on `tool_search` just to load it (bench 41:
795
- * ~6 s per fan-out). Promote it as soon as a spawn succeeds instead: the tool
796
- * list grows exactly as it would after that `tool_search`, one turn earlier,
797
- * and sessions that never spawn keep the smaller prefix.
798
- */
799
- promoteWaitAgentAfterSpawn() {
800
- const index = this.tools.findIndex((t) => t.name === "spawn_agent");
801
- const spawn = index >= 0 ? this.tools[index] : undefined;
802
- if (!spawn)
691
+ /** Append a deferred built-in to the live tool list (append-only). */
692
+ promoteDeferredBuiltin(name) {
693
+ if (!this.deferredBuiltinToolNames.includes(name))
694
+ return undefined;
695
+ const live = this.tools.find((t) => t.name === name);
696
+ if (live)
697
+ return live;
698
+ const [tool] = this.mcp.promote([name]);
699
+ if (tool)
700
+ this.tools.push(tool);
701
+ return tool;
702
+ }
703
+ wrapToPromote(trigger, followUps, when = () => true) {
704
+ const index = this.tools.findIndex((t) => t.name === trigger);
705
+ const tool = index >= 0 ? this.tools[index] : undefined;
706
+ if (!tool)
803
707
  return;
804
708
  this.tools[index] = {
805
- ...spawn,
709
+ ...tool,
806
710
  execute: async (args, context) => {
807
- const result = await spawn.execute(args, context);
808
- if (!this.tools.some((t) => t.name === "wait_agent")) {
809
- this.tools.push(...(this.mcpCatalog?.promote(["wait_agent"]) ?? []));
711
+ const result = await tool.execute(args, context);
712
+ if (when(args)) {
713
+ const missing = followUps.filter((name) => !this.tools.some((t) => t.name === name));
714
+ if (missing.length > 0)
715
+ this.tools.push(...this.mcp.promote(missing));
810
716
  }
811
717
  return result;
812
718
  },
813
719
  };
814
720
  }
815
- /** Append tools, replacing any same-named entry (cached stub → live tool). */
816
- replaceOrPushTools(tools) {
817
- for (const tool of tools) {
818
- const index = this.tools.findIndex((t) => t.name === tool.name);
819
- if (index >= 0)
820
- this.tools[index] = tool;
821
- else
822
- this.tools.push(tool);
823
- }
824
- }
825
- /** Swap already-promoted cached stubs for their live equivalents, in place. */
826
- replaceLivePromotedTools(tools) {
827
- for (const tool of tools) {
828
- const index = this.tools.findIndex((t) => t.name === tool.name);
829
- if (index >= 0)
830
- this.tools[index] = tool;
831
- }
832
- }
833
721
  /**
834
722
  * Publish cached tool definitions into the deferred catalog so `tool_search`
835
- * answers correctly on turn 1. A cached stub carries the real name, one-line
836
- * description and input schema; calling it waits for the live connection and
837
- * then dispatches against the real client, or returns a clear error when that
838
- * server ultimately failed. Live tools replace stubs on connect.
723
+ * answers correctly on turn 1 (see {@link McpToolRegistry.seedFromCache}).
839
724
  */
840
725
  async seedMcpCatalogFromCache(servers) {
841
726
  if (!this.opts.backgroundMcpConnect)
842
727
  return;
843
728
  if (this.opts.allowedTools || !this.settingsManager.get("deferredMcpTools"))
844
729
  return;
845
- let entries;
846
- try {
847
- entries = await this.mcpCatalogCache.entriesFor(servers);
848
- }
849
- catch {
850
- return;
851
- }
852
- const stubs = [];
853
- for (const [serverName, entry] of entries) {
854
- for (const cached of entry.tools) {
855
- if (this.liveMcpTools.has(cached.name))
856
- continue;
857
- this.cachedMcpToolServers.set(cached.name, serverName);
858
- stubs.push(this.buildCachedMcpTool(serverName, cached));
859
- }
860
- }
861
- if (stubs.length === 0)
862
- return;
863
- log("INFO", "mcp", "Seeded deferred tool catalog from cache", {
864
- tools: String(stubs.length),
865
- servers: String(entries.size),
866
- });
867
- this.addCachedMcpTools(stubs);
868
- }
869
- /** Catalog-only registration for cached stubs — never marks them live. */
870
- addCachedMcpTools(stubs) {
871
- this.mcpCatalog ??= new DeferredToolCatalog(this.contextLimits);
872
- this.mcpCatalog.add(stubs);
873
- this.ensureToolSearchTool();
874
- }
875
- buildCachedMcpTool(serverName, cached) {
876
- return {
877
- name: cached.name,
878
- description: cached.description,
879
- parameters: z.record(z.string(), z.unknown()),
880
- rawInputSchema: cached.rawInputSchema,
881
- execute: async (args, context) => {
882
- const live = this.liveMcpTools.get(cached.name);
883
- if (live)
884
- return live.execute(args, context);
885
- const outcome = (await this.mcpManager?.whenConnected(serverName)) ?? {
886
- ok: false,
887
- error: "MCP is disabled for this session",
888
- };
889
- if (!outcome.ok) {
890
- return (`MCP tool ${cached.name} is unavailable: server "${serverName}" did not connect ` +
891
- `(${outcome.error}). This tool was offered from a cached catalog. ` +
892
- `Use a different approach or ask the user to check their MCP configuration.`);
893
- }
894
- const connected = this.liveMcpTools.get(cached.name);
895
- if (!connected) {
896
- return (`MCP tool ${cached.name} no longer exists: server "${serverName}" connected but ` +
897
- `does not expose it. The cached catalog entry was stale.`);
898
- }
899
- return connected.execute(args, context);
900
- },
901
- };
730
+ await this.mcp.seedFromCache(servers);
902
731
  }
903
732
  /**
904
733
  * Resolve a `/name [args]` input to the prompt template it expands into, or
@@ -1085,33 +914,15 @@ export class AgentSession {
1085
914
  resetHookState(originalRequest) {
1086
915
  this.opts.completionReview?.begin(originalRequest);
1087
916
  this.lspManager?.clearPendingDiagnostics();
1088
- this.hookStats = { toolCalls: 0, toolFailures: 0, turns: 0 };
1089
- this.hookText = "";
1090
- this.hookConsecutiveFailures = 0;
1091
- this.hookRepeatedNoProgressCalls = 0;
1092
- this.hookProgressTracker.reset();
1093
- this.hookCycleDetector.reset();
1094
- this.hookCyclicPattern = null;
1095
- this.hookFileEditCounts.clear();
1096
- this.hookToolCalls.clear();
1097
- this.loopBreakInjected = 0;
917
+ this.loopMonitor.reset();
918
+ this.verificationTracker.resetRun();
1098
919
  this.regroundingInjected = false;
1099
- this.hookRecentCalls = [];
1100
- this.semanticLoop = {
1101
- checksUsed: 0,
1102
- lastCheckTurn: 0,
1103
- pending: false,
1104
- verdict: null,
1105
- injected: false,
1106
- };
1107
920
  this.runStartedAt = Date.now();
1108
921
  this.processGateInjected = 0;
922
+ this.planSubmissionNudges = 0;
1109
923
  this.verificationGate.beginRun();
1110
924
  const processes = new Set(this.processManager?.list().map((p) => p.id) ?? []);
1111
- for (const id of this.backgroundVerification.keys()) {
1112
- if (!processes.has(id))
1113
- this.backgroundVerification.delete(id);
1114
- }
925
+ this.verificationTracker.pruneBackground(processes);
1115
926
  this.compactionOccurred = false;
1116
927
  // Post-turn compaction may have landed between runs — adopt its armed
1117
928
  // signal so the re-grounding hook fires for this run exactly as it would
@@ -1135,161 +946,20 @@ export class AgentSession {
1135
946
  }
1136
947
  switch (event.type) {
1137
948
  case "text_delta":
1138
- this.hookText += event.text;
949
+ this.loopMonitor.recordText(event.text);
1139
950
  break;
1140
951
  case "tool_call_start": {
1141
- this.hookToolCalls.set(event.toolCallId, {
1142
- name: event.name,
1143
- args: event.args ?? {},
1144
- revision: this.verificationGate.revision,
1145
- });
1146
- const startClassification = event.name === "bash" && typeof event.args?.command === "string"
1147
- ? classifyVerificationCommand(event.args.command)
1148
- : null;
1149
- if (startClassification &&
1150
- typeof event.args?.command === "string" &&
1151
- (isVerificationCommand(event.args.command) ||
1152
- startClassification.accepted ||
1153
- // Must match the tool_call_end predicate: a snapshot-eligible
1154
- // command that never captured a "before" snapshot is misread at
1155
- // the end as an uncomparable workspace and re-arms the gate.
1156
- startClassification.snapshotEligible === true)) {
1157
- // A check that can rewrite files (--fix, build scripts, emitters)
1158
- // invalidates earlier in-flight evidence AND marks the run as
1159
- // touched. A check that is merely UNRECOGNIZED (`make test`, `deno
1160
- // test`) rewrites nothing we can point to: bumping the revision for
1161
- // it poisoned the gate on green output and re-armed the hook into
1162
- // every later question turn.
1163
- this.verificationGate.recordVerificationAttempt();
1164
- const classification = startClassification;
1165
- if (classification.snapshotEligible && event.args.persist !== true) {
1166
- const call = this.hookToolCalls.get(event.toolCallId);
1167
- call.sourceSnapshot = await captureVerificationSnapshot(this.opts.cwd, [
1168
- ...this.hookFileEditCounts.keys(),
1169
- ]);
1170
- if (call.sourceSnapshot === null)
1171
- this.verificationGate.requireFreshVerification(true, event.args.command);
1172
- }
1173
- else if ((classification.accepted && event.args.persist !== true) ||
1174
- (!classification.accepted && classification.mayMutate)) {
1175
- // Flag the workspace unknown only when tool_call_end can resolve
1176
- // it: a bounded check records pass/fail, a file-rewriting command
1177
- // bumps the revision. An unrecognized read-only check (`biome ci`)
1178
- // or a persistent-shell run records nothing at the end, so
1179
- // flagging it left verified work Unverified forever — and
1180
- // autopilot silently refused every later turn.
1181
- this.verificationGate.requireFreshVerification(!classification.accepted && classification.mayMutate, event.args.command);
1182
- }
952
+ if (await this.verificationTracker.recordToolCallStart(event)) {
1183
953
  await this.persistVerificationState();
1184
954
  }
1185
955
  break;
1186
956
  }
1187
957
  case "tool_call_end": {
1188
- const call = this.hookToolCalls.get(event.toolCallId);
958
+ const call = this.verificationTracker.getToolCall(event.toolCallId);
1189
959
  const name = call?.name ?? "";
1190
960
  const args = call?.args;
1191
- this.hookStats.toolCalls += 1;
1192
- if (event.isError)
1193
- this.hookStats.toolFailures += 1;
1194
- this.hookConsecutiveFailures = event.isError ? this.hookConsecutiveFailures + 1 : 0;
1195
- this.hookRepeatedNoProgressCalls = this.hookProgressTracker.record(name, args, event.result, event.isError);
1196
- this.hookCyclicPattern = this.hookCycleDetector.record(name, args, event.result, event.isError);
1197
- // Semantic-loop judge input: a bounded digest of WHAT was attempted and
1198
- // HOW it came out. Args/results are sliced AT RECORD TIME — a write with
1199
- // a 50 KiB payload or a bash dump must never inflate the ring, and the
1200
- // judge needs shapes, not payloads.
1201
- this.hookRecentCalls.push({
1202
- tool: name,
1203
- args: args === undefined ? "" : JSON.stringify(args).slice(0, 300),
1204
- ok: !event.isError,
1205
- result: event.result.slice(0, 400),
1206
- });
1207
- if (this.hookRecentCalls.length > MAX_SEMANTIC_LOOP_CALLS) {
1208
- this.hookRecentCalls.splice(0, this.hookRecentCalls.length - MAX_SEMANTIC_LOOP_CALLS);
1209
- }
1210
- // Only host-observed successful mutations and trustworthy check results
1211
- // affect approval. The model's text is never evidence.
1212
- let verificationChanged = false;
1213
- if (!event.isError && args) {
1214
- if (name === "edit" || name === "write") {
1215
- // Check-owning files (tsconfig.json, pytest.ini, vitest.config.ts …)
1216
- // are tracked even when they are not source code: editing one is how
1217
- // a red suite is turned green without fixing anything. A multi-file
1218
- // edit records every file; its combined diff is scanned for each.
1219
- const addedText = name === "write"
1220
- ? String(args.content ?? "")
1221
- : extractAddedLines(event.details?.diff ?? event.result);
1222
- for (const filePath of editTargetPaths(args)) {
1223
- if (isCodeFilePath(filePath) || isCheckOwnFile(filePath)) {
1224
- this.verificationGate.recordMutation(filePath, addedText);
1225
- verificationChanged = true;
1226
- }
1227
- }
1228
- }
1229
- }
1230
- if (args && call && name === "bash") {
1231
- const command = typeof args.command === "string" ? args.command : "";
1232
- const classification = classifyVerificationCommand(command);
1233
- if (args.review === true && event.result === REVIEW_REJECTED_BEFORE_START) {
1234
- // The core tool rejected its arguments BEFORE spawning. Never mint
1235
- // a pass, or poison the failed-check ledger with an unexecuted check.
1236
- // A child's output cannot match: bash always prefixes it with Exit code.
1237
- this.verificationGate.recordRejectedCheck(command, REVIEW_REJECTED_BEFORE_START);
1238
- delete call.sourceSnapshot;
1239
- verificationChanged = true;
1240
- }
1241
- else if (classification.accepted || classification.snapshotEligible) {
1242
- // A foreground check that outlived the default budget was moved to
1243
- // the background, not failed: track it to its real exit the same way.
1244
- const autoBackgroundId = event.isError ? undefined : autoBackgroundedId(event.result);
1245
- if ((autoBackgroundId !== undefined || args.run_in_background === true) &&
1246
- !event.isError &&
1247
- args.persist !== true) {
1248
- const id = autoBackgroundId ?? /^ID:\s*(\S+)/m.exec(event.result)?.[1];
1249
- // No parseable ID means the check cannot be tracked to a real exit
1250
- // code — no evidence either way. Recording a FAILURE here made
1251
- // every later green run of a different spelling look owed.
1252
- if (id)
1253
- this.backgroundVerification.set(id, {
1254
- revision: call.revision,
1255
- command,
1256
- ...(classification.snapshotEligible
1257
- ? { sourceSnapshot: call.sourceSnapshot ?? null }
1258
- : {}),
1259
- });
1260
- else if (classification.snapshotEligible)
1261
- this.verificationGate.requireFreshVerification(true, command);
1262
- }
1263
- else if (args.persist === true) {
1264
- // Persistent-shell checks are not bounded evidence (steering can
1265
- // interleave): neither a pass nor a failure. A recorded failure
1266
- // here blocked approval for sessions that prefer the shell.
1267
- }
1268
- else if (classification.snapshotEligible) {
1269
- await this.finishSnapshotVerification({ command, revision: call.revision, sourceSnapshot: call.sourceSnapshot ?? null }, !event.isError && /^Exit code:\s*0(?:\s|$)/i.test(event.result.trim()));
1270
- verificationChanged = true;
1271
- }
1272
- else {
1273
- if (!event.isError && /^Exit code:\s*0(?:\s|$)/i.test(event.result.trim())) {
1274
- this.verificationGate.recordVerification(call.revision, command);
1275
- }
1276
- else {
1277
- this.verificationGate.recordFailedVerification(command, call.revision);
1278
- }
1279
- verificationChanged = true;
1280
- }
1281
- delete call.sourceSnapshot;
1282
- }
1283
- else if (classification.candidate) {
1284
- // Green but untrusted: remember WHY so the demand can tell the
1285
- // agent which command shape actually clears the gate.
1286
- this.verificationGate.recordRejectedCheck(command, classification.reason);
1287
- }
1288
- }
1289
- if (!event.isError && args && name === "task_output" && typeof args.id === "string") {
1290
- verificationChanged =
1291
- (await this.recordFinishedBackgroundVerification(args.id)) || verificationChanged;
1292
- }
961
+ this.loopMonitor.recordToolEnd(name, args, event.result, event.isError);
962
+ const verificationChanged = await this.verificationTracker.recordToolCallEnd(event, call, this.planModeRef.current);
1293
963
  if (verificationChanged)
1294
964
  await this.persistVerificationState();
1295
965
  // Tool results are what push the run over the review gate, and they all
@@ -1299,7 +969,7 @@ export class AgentSession {
1299
969
  break;
1300
970
  }
1301
971
  case "turn_end":
1302
- this.hookStats.turns = event.turn;
972
+ this.loopMonitor.recordTurn(event.turn);
1303
973
  this.refreshHookArming();
1304
974
  for (let index = this.messages.length - 1; index >= 0; index--) {
1305
975
  const anchor = this.messages[index];
@@ -1476,51 +1146,26 @@ export class AgentSession {
1476
1146
  // Deterministic stuck verdict, computed once and shared: the semantic
1477
1147
  // judge must not spend tokens on a burst the deterministic breaker is
1478
1148
  // about to correct itself.
1479
- const deterministicDecision = evaluateLoopBreak({
1480
- consecutiveFailures: this.hookConsecutiveFailures,
1481
- repeatedNoProgressCalls: this.hookRepeatedNoProgressCalls,
1482
- textRepetitionDetected: detectTextRepetition(this.hookText),
1483
- ...(this.hookCyclicPattern ? { cyclicPattern: this.hookCyclicPattern } : {}),
1484
- });
1149
+ const deterministicDecision = this.loopMonitor.evaluateLoopBreak();
1485
1150
  this.maybeStartSemanticLoopCheck(deterministicDecision.shouldBreak);
1486
1151
  // Two-stage loop-breaker: stage 1 nudges; a FRESH detection after that
1487
1152
  // injects the harsher final stop-and-report prompt. Signals reset after
1488
1153
  // each injection so stage 2 only fires on new evidence.
1489
- if (this.loopBreakInjected < 2) {
1490
- const decision = deterministicDecision;
1491
- if (decision.shouldBreak) {
1492
- const stage = this.loopBreakInjected === 0 ? 1 : 2;
1493
- this.loopBreakInjected = stage;
1494
- this.hookProgressTracker.reset();
1495
- this.hookCycleDetector.reset();
1496
- this.hookCyclicPattern = null;
1497
- this.hookConsecutiveFailures = 0;
1498
- this.hookRepeatedNoProgressCalls = 0;
1499
- // The deterministic breaker owns this burst — a semantic verdict from
1500
- // the same burst must not double-correct on the next poll.
1501
- this.semanticLoop.verdict = null;
1502
- // Clear the text buffer too — otherwise a stage-1 text-repetition
1503
- // trigger still sees the same repeated tail on the next check and
1504
- // escalates to stage 2 on stale evidence.
1505
- this.hookText = "";
1506
- log("INFO", "loop-break", "Injecting loop-break nudge", {
1507
- stage: String(stage),
1508
- reasons: decision.reasons.join(", "),
1509
- });
1510
- this.eventBus.emit("hook", { kind: "loop_break" });
1511
- return [buildLoopBreakMessage(decision.reasons, stage === 2)];
1512
- }
1154
+ const stage = this.loopMonitor.takeLoopBreakStage(deterministicDecision);
1155
+ if (stage !== null) {
1156
+ log("INFO", "loop-break", "Injecting loop-break nudge", {
1157
+ stage: String(stage),
1158
+ reasons: deterministicDecision.reasons.join(", "),
1159
+ });
1160
+ this.eventBus.emit("hook", { kind: "loop_break" });
1161
+ return [buildLoopBreakMessage(deterministicDecision.reasons, stage === 2)];
1513
1162
  }
1514
1163
  // Semantic loop-break: an LLM verdict (started by maybeStartSemanticLoopCheck
1515
1164
  // on a suspicious-but-syntactically-quiet burst) is consumed here, exactly
1516
1165
  // once, with the deterministic breaker getting priority above. Fail-open:
1517
1166
  // no verdict or no-loop verdict injects nothing.
1518
- if (this.semanticLoop.verdict && !this.semanticLoop.injected) {
1519
- const verdict = this.semanticLoop.verdict;
1520
- this.semanticLoop.injected = true;
1521
- this.semanticLoop.verdict = null;
1522
- // The judged burst has been addressed; a fresh burst must re-accumulate.
1523
- this.hookConsecutiveFailures = 0;
1167
+ const verdict = this.loopMonitor.takeSemanticVerdict();
1168
+ if (verdict) {
1524
1169
  log("INFO", "loop-break", "Injecting semantic loop-break steering", {
1525
1170
  reason: verdict.reason,
1526
1171
  });
@@ -1540,34 +1185,26 @@ export class AgentSession {
1540
1185
  * Fire-and-forget: the call runs while the next turn streams, and a finished
1541
1186
  * verdict is consumed by the NEXT steering poll — never blocking a turn. */
1542
1187
  maybeStartSemanticLoopCheck(deterministicBreak) {
1543
- if (!shouldRunSemanticLoopCheck({
1544
- consecutiveFailures: this.hookConsecutiveFailures,
1545
- totalFailures: this.hookStats.toolFailures,
1546
- turns: this.hookStats.turns,
1547
- lastCheckTurn: this.semanticLoop.lastCheckTurn,
1548
- checksUsed: this.semanticLoop.checksUsed,
1549
- checkPending: this.semanticLoop.pending,
1550
- deterministicBreak,
1551
- })) {
1188
+ const monitor = this.loopMonitor;
1189
+ if (!monitor.shouldRunSemanticCheck(deterministicBreak)) {
1552
1190
  return;
1553
1191
  }
1554
- // resetHookState replaces this object on every prompt. A late judge must
1192
+ // resetHookState replaces the run state on every prompt. A late judge must
1555
1193
  // not publish a verdict or consume the next run's budget/cooldown.
1556
- const runState = this.semanticLoop;
1557
- runState.pending = true;
1194
+ const runState = monitor.beginSemanticCheck();
1558
1195
  log("INFO", "loop-break", "Starting semantic loop judge", {
1559
- turn: String(this.hookStats.turns),
1560
- consecutiveFailures: String(this.hookConsecutiveFailures),
1561
- recentCalls: String(this.hookRecentCalls.length),
1196
+ turn: String(monitor.stats.turns),
1197
+ consecutiveFailures: String(monitor.consecutiveFailures),
1198
+ recentCalls: String(monitor.recentCalls.length),
1562
1199
  });
1563
1200
  void (async () => {
1564
1201
  try {
1565
- const prompt = buildSemanticLoopJudgePrompt(this.hookRecentCalls, this.originalRequest);
1202
+ const prompt = buildSemanticLoopJudgePrompt(monitor.recentCalls, this.originalRequest);
1566
1203
  const raw = await (this.opts.semanticLoopJudge?.(prompt) ??
1567
1204
  this.callSemanticLoopJudge(prompt));
1568
1205
  const verdict = parseSemanticLoopVerdict(raw);
1569
- if (this.semanticLoop === runState && verdict?.loop)
1570
- runState.verdict = verdict;
1206
+ if (verdict?.loop)
1207
+ monitor.publishSemanticVerdict(runState, verdict);
1571
1208
  }
1572
1209
  catch (error) {
1573
1210
  // Fail open: judge errors never stop a run. Budget and cooldown are
@@ -1577,11 +1214,7 @@ export class AgentSession {
1577
1214
  });
1578
1215
  }
1579
1216
  finally {
1580
- if (this.semanticLoop === runState) {
1581
- runState.pending = false;
1582
- runState.checksUsed += 1;
1583
- runState.lastCheckTurn = this.hookStats.turns;
1584
- }
1217
+ monitor.settleSemanticCheck(runState);
1585
1218
  }
1586
1219
  })();
1587
1220
  }
@@ -1677,23 +1310,7 @@ export class AgentSession {
1677
1310
  * with — so reuse the same stuck signals that drive the loop-breaker.
1678
1311
  */
1679
1312
  shouldExtendTurnBudget(ctx) {
1680
- const refusals = [];
1681
- const stuck = evaluateLoopBreak({
1682
- consecutiveFailures: this.hookConsecutiveFailures,
1683
- repeatedNoProgressCalls: this.hookRepeatedNoProgressCalls,
1684
- textRepetitionDetected: detectTextRepetition(this.hookText),
1685
- ...(this.hookCyclicPattern ? { cyclicPattern: this.hookCyclicPattern } : {}),
1686
- });
1687
- if (stuck.shouldBreak)
1688
- refusals.push(...stuck.reasons);
1689
- // Stage 2 means the loop-breaker already detected spinning twice and told
1690
- // the agent to stop and report. Do not overrule that with more turns.
1691
- if (this.loopBreakInjected >= 2)
1692
- refusals.push("loop-breaker already escalated");
1693
- const { toolCalls, toolFailures } = this.hookStats;
1694
- if (toolCalls > 0 && toolFailures / toolCalls > TURN_EXTENSION_MAX_FAILURE_RATIO) {
1695
- refusals.push(`${toolFailures}/${toolCalls} tool calls failed`);
1696
- }
1313
+ const refusals = this.loopMonitor.turnExtensionRefusals();
1697
1314
  const granted = refusals.length === 0;
1698
1315
  log("INFO", "turn-budget", granted ? "Extending turn budget" : "Refusing turn-budget extension", {
1699
1316
  turn: String(ctx.turn),
@@ -1746,11 +1363,7 @@ export class AgentSession {
1746
1363
  async getHookFollowUpMessages() {
1747
1364
  // Exit notifications and task_output refer to the same host process record.
1748
1365
  // Do not force an extra polling tool/turn merely to acknowledge a known exit.
1749
- let backgroundChanged = false;
1750
- for (const id of this.backgroundVerification.keys()) {
1751
- backgroundChanged =
1752
- (await this.recordFinishedBackgroundVerification(id)) || backgroundChanged;
1753
- }
1366
+ const backgroundChanged = await this.verificationTracker.settleFinishedBackground();
1754
1367
  if (backgroundChanged)
1755
1368
  await this.persistVerificationState();
1756
1369
  // Edits return immediately; only the completion boundary waits for remaining
@@ -1826,6 +1439,22 @@ export class AgentSession {
1826
1439
  // verification demand above instead of manufacturing a separate hook.
1827
1440
  if (diagnosticMessages.length > 0)
1828
1441
  return diagnosticMessages;
1442
+ if (shouldNudgePlanSubmission({
1443
+ enabled: this.opts.planSubmissionGate === true,
1444
+ planMode: this.planModeRef.current,
1445
+ aborted: this.opts.signal?.aborted === true,
1446
+ nudgesThisRun: this.planSubmissionNudges,
1447
+ })) {
1448
+ this.planSubmissionNudges += 1;
1449
+ log("INFO", "plan-gate", "Run stopping in plan mode without a submitted plan; reminding");
1450
+ return [
1451
+ {
1452
+ role: "user",
1453
+ content: buildNotificationSteeringText([PLAN_SUBMISSION_NUDGE]),
1454
+ provenance: { source: "runtime", kind: "notification", visibility: "hidden" },
1455
+ },
1456
+ ];
1457
+ }
1829
1458
  if (this.opts.completionReview) {
1830
1459
  const followUp = await this.opts.completionReview.followUp((request, signal) => this.callCompletionReviewer(request, signal), this.opts.signal);
1831
1460
  await this.persistCompletionReviewState();
@@ -2052,6 +1681,11 @@ export class AgentSession {
2052
1681
  maxTurnToolResultChars: resolveSessionTurnToolResultCharLimit(this.model, this.provider, accountId),
2053
1682
  // Warn when web/MCP output contains instruction-like text (see injection-detect.ts).
2054
1683
  transformToolResult: flagUntrustedToolResult,
1684
+ // A deferred built-in called by its advertised name runs directly
1685
+ // instead of costing a turn on "Unknown tool" then tool_search.
1686
+ ...(options.disableTools
1687
+ ? {}
1688
+ : { resolveTool: (name) => this.promoteDeferredBuiltin(name) }),
2055
1689
  // Self-correction hooks (same as the TUI): loop-break + re-grounding are
2056
1690
  // polled mid-loop; the ideal review is polled when the agent would stop.
2057
1691
  getSteeringMessages: () => this.getHookSteeringMessages(),
@@ -2385,11 +2019,12 @@ export class AgentSession {
2385
2019
  // there avoids tearing down a live stdio child and
2386
2020
  // gambling on a `npx` re-spawn that could fail and drop the tools.
2387
2021
  const glmInvolved = this.provider === "glm" || prevProvider === "glm";
2388
- if (this.mcpManager && glmInvolved) {
2022
+ const mcpManager = this.mcp.manager;
2023
+ if (mcpManager && glmInvolved) {
2389
2024
  // Remove old MCP tools
2390
2025
  this.tools = this.tools.filter((t) => !t.name.startsWith("mcp__"));
2391
2026
  // Disconnect old MCP servers
2392
- await this.mcpManager.dispose();
2027
+ await mcpManager.dispose();
2393
2028
  // Connect new MCP servers for the new provider
2394
2029
  try {
2395
2030
  let apiKey;
@@ -2406,13 +2041,11 @@ export class AgentSession {
2406
2041
  const servers = await getAllMcpServers(this.provider, apiKey, this.cwd, {
2407
2042
  allowProjectScope: this.settingsManager.isProjectTrusted(this.cwd),
2408
2043
  });
2409
- const mcpTools = await this.mcpManager.connectAll(servers);
2044
+ const mcpTools = await mcpManager.connectAll(servers);
2410
2045
  // Drop stale MCP tools from both the live set and deferred catalog before
2411
2046
  // re-adding. Some tools may already have been promoted out of the catalog.
2412
2047
  this.tools = this.tools.filter((t) => !t.name.startsWith("mcp__"));
2413
- this.mcpCatalog?.removeWhere((name) => name.startsWith("mcp__"));
2414
- this.liveMcpTools.clear();
2415
- this.cachedMcpToolServers.clear();
2048
+ this.mcp.clearMcpTools();
2416
2049
  this.addMcpTools(mcpTools);
2417
2050
  }
2418
2051
  catch (err) {
@@ -2455,10 +2088,7 @@ export class AgentSession {
2455
2088
  this.checkpointGeneration = loaded.header.generation ?? 0;
2456
2089
  this.currentLeafId = loaded.header.leafId;
2457
2090
  this.setSessionPath(loaded.path);
2458
- this.nolanTurns = this.sessionManager.getNolanTurns(loaded.entries, loaded.header.leafId);
2459
- this.autopilotMarkers = this.sessionManager.getAutopilotMarkers(loaded.entries, loaded.header.leafId);
2460
- this.appMarkers = this.sessionManager.getAppMarkers(loaded.entries, loaded.header.leafId);
2461
- this.turnMetrics = this.sessionManager.getTurnMetrics(loaded.entries);
2091
+ this.markers.restore(loaded.entries, loaded.header.leafId);
2462
2092
  this.lastPersistedIndex = this.messages.length;
2463
2093
  this.providerContext = null;
2464
2094
  await this.subAgentManager?.rebindParentSession(this.sessionId);
@@ -2498,10 +2128,7 @@ export class AgentSession {
2498
2128
  await this.persistMessage(message);
2499
2129
  }
2500
2130
  this.lastPersistedIndex = this.messages.length;
2501
- await this.rePersistTurnMetrics();
2502
- await this.rePersistNolanTurns();
2503
- await this.rePersistAutopilotMarkers();
2504
- await this.rePersistAppMarkers();
2131
+ await this.markers.rePersistAll();
2505
2132
  await this.persistVerificationState();
2506
2133
  await this.persistAppMarker("compaction", {
2507
2134
  originalCount: result.originalCount,
@@ -2860,12 +2487,17 @@ export class AgentSession {
2860
2487
  });
2861
2488
  }
2862
2489
  async newSession(preserveConversation = false) {
2490
+ // A background post-turn compaction replaces `this.messages` when it
2491
+ // lands. Letting it finish after the reset would swap the old history back
2492
+ // into the fresh session (e.g. an accepted plan's build would inherit the
2493
+ // whole planning conversation and lose the approved-plan prompt).
2494
+ await this.settlePostTurnCompaction();
2863
2495
  this.cacheDiagnostics.reset();
2864
2496
  // Approved-plan execution is a clean checkpoint of the same conversation;
2865
2497
  // explicit new sessions reset the conversation identity.
2866
2498
  if (!preserveConversation) {
2867
2499
  this.verificationGate.reset();
2868
- this.backgroundVerification.clear();
2500
+ this.verificationTracker.clearBackground();
2869
2501
  this.conversationId = "";
2870
2502
  this.checkpointGeneration = 0;
2871
2503
  this.sessionPreview = "";
@@ -2877,10 +2509,7 @@ export class AgentSession {
2877
2509
  // turns / autopilot verdicts / app markers linger in memory, show up in the
2878
2510
  // new session's /history, and get re-persisted into the new file by the
2879
2511
  // next compaction — the cross-session duplicate-marker propagation bug.
2880
- this.nolanTurns = [];
2881
- this.autopilotMarkers = [];
2882
- this.appMarkers = [];
2883
- this.turnMetrics = [];
2512
+ this.markers.clear();
2884
2513
  const basePrompt = await this.buildBasePrompt(false, undefined);
2885
2514
  this.baseSystemPrompt = basePrompt;
2886
2515
  this.messages = [{ role: "system", content: this.withSystemPromptTail(basePrompt) }];
@@ -3002,9 +2631,7 @@ export class AgentSession {
3002
2631
  else {
3003
2632
  used = calculateActiveContextTokens(this.messages);
3004
2633
  }
3005
- const costUsd = this.turnMetrics.length > 0 && this.turnMetrics.every((m) => m.cost.status === "known")
3006
- ? this.turnMetrics.reduce((sum, m) => sum + (m.cost.status === "known" ? m.cost.usd : 0), 0)
3007
- : undefined;
2634
+ const costUsd = this.markers.knownCostUsd();
3008
2635
  return costUsd === undefined ? { used, size } : { used, size, costUsd };
3009
2636
  }
3010
2637
  /**
@@ -3128,6 +2755,11 @@ export class AgentSession {
3128
2755
  */
3129
2756
  async setPlanMode(active) {
3130
2757
  this.planModeRef.current = active;
2758
+ // Plan mode always ends with `exit_plan`; a deferred one must be callable
2759
+ // without a tool_search turn. Append-only, like any promotion.
2760
+ if (active && !this.tools.some((t) => t.name === "exit_plan")) {
2761
+ this.tools.push(...this.mcp.promote(["exit_plan"]));
2762
+ }
3131
2763
  // Entering plan mode discards any prior approved-plan contract (a new plan
3132
2764
  // is about to be drafted); exiting keeps it (set explicitly via accept).
3133
2765
  if (active)
@@ -3331,7 +2963,10 @@ export class AgentSession {
3331
2963
  orchestrationPolicyTail() {
3332
2964
  if (this.opts.orchestrationPrompt === false)
3333
2965
  return "";
3334
- return applyAsyncSubagentPolicy("", this.provider, this.model, this.thinkingLevel, this.tools.map((tool) => tool.name)).trim();
2966
+ return applyAsyncSubagentPolicy("", this.provider, this.model, this.thinkingLevel,
2967
+ // Deferred spawn_agent/wait_agent are one call away (resolveTool loads
2968
+ // them on first use), so the policy must still see them.
2969
+ [...this.tools.map((tool) => tool.name), ...this.deferredBuiltinToolNames]).trim();
3335
2970
  }
3336
2971
  /**
3337
2972
  * Re-render the uncached tail of messages[0] in place. The cached prefix is
@@ -3353,12 +2988,7 @@ export class AgentSession {
3353
2988
  return this.messages;
3354
2989
  }
3355
2990
  getTurnMetrics() {
3356
- return this.turnMetrics.map((metric) => ({
3357
- ...metric,
3358
- usage: { ...metric.usage },
3359
- timing: { ...metric.timing },
3360
- cost: { ...metric.cost },
3361
- }));
2991
+ return this.markers.getTurnMetrics();
3362
2992
  }
3363
2993
  async persistTurnMetric(event) {
3364
2994
  if (event.stopReason === "error")
@@ -3385,31 +3015,23 @@ export class AgentSession {
3385
3015
  reason: "No authoritative effective-dated provider pricing is available",
3386
3016
  },
3387
3017
  };
3388
- this.turnMetrics.push(payload);
3018
+ this.markers.addTurnMetric(payload);
3389
3019
  // Internal diagnostics piggyback on the authoritative per-turn metric —
3390
3020
  // one source of truth, and the record flushes to disk every turn.
3391
3021
  this.diagnosticsRecorder?.recordTurnMetric(payload);
3392
- if (this.sessionPath)
3393
- await this.sessionManager.appendTurnMetric(this.sessionPath, payload);
3394
- }
3395
- async rePersistTurnMetrics() {
3396
- if (!this.sessionPath)
3397
- return;
3398
- for (const metric of this.turnMetrics) {
3399
- await this.sessionManager.appendTurnMetric(this.sessionPath, metric);
3400
- }
3022
+ await this.markers.persistTurnMetric(payload);
3401
3023
  }
3402
3024
  /** Nolan Grout (mentor) turns recorded against this session, in record order. Used
3403
3025
  * by the host to interleave Nolan's advisory exchanges back into the transcript
3404
3026
  * on resume. Never part of the LLM message history. */
3405
3027
  getNolanTurns() {
3406
- return this.nolanTurns;
3028
+ return this.markers.getNolanTurns();
3407
3029
  }
3408
3030
  /** Autopilot verdict markers recorded against this session, in record order.
3409
3031
  * Used by the host to interleave the auto-review loop's markers back into
3410
3032
  * the transcript on resume, mirroring `getNolanTurns`. */
3411
3033
  getAutopilotMarkers() {
3412
- return this.autopilotMarkers;
3034
+ return this.markers.getAutopilotMarkers();
3413
3035
  }
3414
3036
  /** Non-system messages that are actually on disk. Transcript markers anchor
3415
3037
  * against this (not the in-memory list, which can run ahead after a failed
@@ -3424,15 +3046,7 @@ export class AgentSession {
3424
3046
  * file, so the new file carries positions that match its own transcript.
3425
3047
  */
3426
3048
  remapMarkerAnchors(remap) {
3427
- if (!remap)
3428
- return;
3429
- const move = (payload) => ({
3430
- ...payload,
3431
- afterMessageCount: remapAnchorForCompaction(payload.afterMessageCount, remap),
3432
- });
3433
- this.nolanTurns = this.nolanTurns.map(move);
3434
- this.autopilotMarkers = this.autopilotMarkers.map(move);
3435
- this.appMarkers = this.appMarkers.map(move);
3049
+ this.markers.remapAnchors(remap);
3436
3050
  }
3437
3051
  /**
3438
3052
  * Record one Nolan Grout (mentor agent) turn against this build session: the
@@ -3445,39 +3059,7 @@ export class AgentSession {
3445
3059
  * appendEntry's own handling.
3446
3060
  */
3447
3061
  async persistNolanTurn(question, reply) {
3448
- const afterMessageCount = this.persistedTranscriptCount();
3449
- const payload = { version: 1, question, reply, afterMessageCount };
3450
- this.nolanTurns.push(payload);
3451
- if (!this.sessionPath)
3452
- return;
3453
- const entry = {
3454
- type: "custom",
3455
- kind: NOLAN_TURN_CUSTOM_KIND,
3456
- id: crypto.randomUUID(),
3457
- parentId: null,
3458
- timestamp: new Date().toISOString(),
3459
- data: payload,
3460
- };
3461
- await this.sessionManager.appendEntry(this.sessionPath, entry);
3462
- }
3463
- /** Re-append the in-memory Nolan turns to the current session file. Called after
3464
- * a continuation/compaction file is created so Nolan's advisory history isn't
3465
- * lost when the session is rewritten (those rewrites only re-persist
3466
- * messages). Each turn keeps its original `afterMessageCount` anchor. */
3467
- async rePersistNolanTurns() {
3468
- if (!this.sessionPath)
3469
- return;
3470
- for (const payload of this.nolanTurns) {
3471
- const entry = {
3472
- type: "custom",
3473
- kind: NOLAN_TURN_CUSTOM_KIND,
3474
- id: crypto.randomUUID(),
3475
- parentId: null,
3476
- timestamp: new Date().toISOString(),
3477
- data: stripRecordedPosition(payload),
3478
- };
3479
- await this.sessionManager.appendEntry(this.sessionPath, entry);
3480
- }
3062
+ await this.markers.recordNolanTurn(question, reply, this.persistedTranscriptCount());
3481
3063
  }
3482
3064
  /**
3483
3065
  * Record one autopilot verdict marker (prompted / done / human / capped)
@@ -3487,23 +3069,6 @@ export class AgentSession {
3487
3069
  * instead of dropping the marker or falling back to a raw verdict string.
3488
3070
  * No-op persistence for transient sessions (kept in memory only).
3489
3071
  */
3490
- async recordFinishedBackgroundVerification(id) {
3491
- const started = this.backgroundVerification.get(id);
3492
- const proc = this.processManager?.list().find((entry) => entry.id === id);
3493
- if (!started || !proc || proc.exitCode === null)
3494
- return false;
3495
- if (started.sourceSnapshot !== undefined) {
3496
- await this.finishSnapshotVerification(started, proc.exitCode === 0);
3497
- }
3498
- else if (proc.exitCode === 0) {
3499
- this.verificationGate.recordVerification(started.revision, started.command);
3500
- }
3501
- else {
3502
- this.verificationGate.recordFailedVerification(started.command, started.revision);
3503
- }
3504
- this.backgroundVerification.delete(id);
3505
- return true;
3506
- }
3507
3072
  getVerificationEvidence() {
3508
3073
  return this.verificationGate.evidence();
3509
3074
  }
@@ -3515,39 +3080,8 @@ export class AgentSession {
3515
3080
  evidence: this.verificationGate.evidence("run"),
3516
3081
  };
3517
3082
  }
3518
- async finishSnapshotVerification(check, passed) {
3519
- const after = check.sourceSnapshot
3520
- ? await captureVerificationSnapshot(this.opts.cwd, [...this.hookFileEditCounts.keys()])
3521
- : null;
3522
- if (after === null || after !== check.sourceSnapshot) {
3523
- this.verificationGate.requireFreshVerification(true, check.command);
3524
- this.verificationGate.recordRejectedCheck(check.command, after === null
3525
- ? "Workspace inputs could not be compared; run a read-only check after the command"
3526
- : "Command changed workspace inputs; run checks against the changed source");
3527
- if (!passed)
3528
- this.verificationGate.recordFailedVerification(check.command);
3529
- }
3530
- else if (passed) {
3531
- const classification = classifyVerificationCommand(check.command);
3532
- if (classification.snapshotPreserveOnly) {
3533
- this.verificationGate.recordRejectedCheck(check.command, classification.reason);
3534
- }
3535
- else {
3536
- this.verificationGate.recordVerification(check.revision, check.command);
3537
- }
3538
- }
3539
- else {
3540
- this.verificationGate.recordFailedVerification(check.command, check.revision);
3541
- }
3542
- }
3543
3083
  getVerificationProblem() {
3544
- if ([...this.hookToolCalls.values()].some((call) => call.sourceSnapshot !== undefined)) {
3545
- return "Unverified: a build is still running or its workspace comparison is pending.";
3546
- }
3547
- return (this.verificationGate.verificationProblem() ??
3548
- (this.backgroundVerification.size > 0
3549
- ? "Unverified: a background check is still running or its result has not been confirmed."
3550
- : null));
3084
+ return this.verificationTracker.verificationProblem();
3551
3085
  }
3552
3086
  async persistCompletionReviewState() {
3553
3087
  if (!this.sessionPath || !this.opts.completionReview)
@@ -3580,50 +3114,13 @@ export class AgentSession {
3580
3114
  await this.sessionManager.appendEntry(this.sessionPath, entry);
3581
3115
  }
3582
3116
  async persistAutopilotMarker(phase, extra) {
3583
- const afterMessageCount = this.persistedTranscriptCount();
3584
- const payload = {
3585
- version: 1,
3586
- phase,
3587
- afterMessageCount,
3588
- ...(extra?.reason !== undefined ? { reason: extra.reason } : {}),
3589
- ...(extra?.body !== undefined ? { body: extra.body } : {}),
3590
- };
3591
- this.autopilotMarkers.push(payload);
3592
- if (!this.sessionPath)
3593
- return;
3594
- const entry = {
3595
- type: "custom",
3596
- kind: AUTOPILOT_MARKER_CUSTOM_KIND,
3597
- id: crypto.randomUUID(),
3598
- parentId: null,
3599
- timestamp: new Date().toISOString(),
3600
- data: payload,
3601
- };
3602
- await this.sessionManager.appendEntry(this.sessionPath, entry);
3603
- }
3604
- /** Re-append the in-memory autopilot markers to the current session file.
3605
- * Mirrors `rePersistNolanTurns` — called after a continuation/compaction file
3606
- * is created so the auto-review history survives the rewrite. */
3607
- async rePersistAutopilotMarkers() {
3608
- if (!this.sessionPath)
3609
- return;
3610
- for (const payload of this.autopilotMarkers) {
3611
- const entry = {
3612
- type: "custom",
3613
- kind: AUTOPILOT_MARKER_CUSTOM_KIND,
3614
- id: crypto.randomUUID(),
3615
- parentId: null,
3616
- timestamp: new Date().toISOString(),
3617
- data: stripRecordedPosition(payload),
3618
- };
3619
- await this.sessionManager.appendEntry(this.sessionPath, entry);
3620
- }
3117
+ await this.markers.recordAutopilotMarker(phase, extra, this.persistedTranscriptCount());
3621
3118
  }
3622
3119
  /** App transcript markers recorded against this session, in record order.
3623
3120
  * Used by the host to interleave display-only rows (plan banner, task
3624
3121
  * header, errors, user-bubble hints) back into the transcript on resume. */
3625
3122
  getAppMarkers() {
3626
- return this.appMarkers;
3123
+ return this.markers.getAppMarkers();
3627
3124
  }
3628
3125
  /**
3629
3126
  * Record one app transcript marker (display-only row) against this session.
@@ -3635,20 +3132,7 @@ export class AgentSession {
3635
3132
  * No-op persistence for transient sessions.
3636
3133
  */
3637
3134
  async persistAppMarker(kind, data, anchorOffset = 0) {
3638
- const afterMessageCount = this.persistedTranscriptCount() + anchorOffset;
3639
- const payload = { version: 1, kind, afterMessageCount, data };
3640
- this.appMarkers.push(payload);
3641
- if (!this.sessionPath)
3642
- return;
3643
- const entry = {
3644
- type: "custom",
3645
- kind: APP_MARKER_CUSTOM_KIND,
3646
- id: crypto.randomUUID(),
3647
- parentId: null,
3648
- timestamp: new Date().toISOString(),
3649
- data: payload,
3650
- };
3651
- await this.sessionManager.appendEntry(this.sessionPath, entry);
3135
+ await this.markers.recordAppMarker(kind, data, this.persistedTranscriptCount() + anchorOffset);
3652
3136
  }
3653
3137
  /**
3654
3138
  * Open the run journal for one `RunLifecycle` generation.
@@ -3677,24 +3161,6 @@ export class AgentSession {
3677
3161
  outcome,
3678
3162
  });
3679
3163
  }
3680
- /** Re-append the in-memory app markers to the current session file. Mirrors
3681
- * `rePersistNolanTurns` — called after a continuation/compaction file is
3682
- * created so display-only rows survive the rewrite. */
3683
- async rePersistAppMarkers() {
3684
- if (!this.sessionPath)
3685
- return;
3686
- for (const payload of this.appMarkers) {
3687
- const entry = {
3688
- type: "custom",
3689
- kind: APP_MARKER_CUSTOM_KIND,
3690
- id: crypto.randomUUID(),
3691
- parentId: null,
3692
- timestamp: new Date().toISOString(),
3693
- data: stripRecordedPosition(payload),
3694
- };
3695
- await this.sessionManager.appendEntry(this.sessionPath, entry);
3696
- }
3697
- }
3698
3164
  /**
3699
3165
  * Rewrite a draft prompt into a tighter, terminology-correct version using
3700
3166
  * the ACTIVE provider/model. A stateless one-off LLM call (no agent loop, no
@@ -3936,7 +3402,7 @@ export class AgentSession {
3936
3402
  this.stopBackgroundProcesses();
3937
3403
  this.lspManager?.shutdownAll();
3938
3404
  this.debugManager?.shutdown();
3939
- await Promise.all([this.subAgentManager?.shutdownAll(), this.mcpManager?.dispose()]);
3405
+ await Promise.all([this.subAgentManager?.shutdownAll(), this.mcp.manager?.dispose()]);
3940
3406
  await this.extensionLoader.deactivateAll();
3941
3407
  this.setSessionPath("");
3942
3408
  this.eventBus.removeAllListeners();
@@ -3981,7 +3447,7 @@ export class AgentSession {
3981
3447
  .reverse()
3982
3448
  .find((entry) => entry.type === "custom" && entry.kind === COMPLETION_REVIEW_STATE_KIND);
3983
3449
  this.opts.completionReview?.restore(savedCompletionReview?.type === "custom" ? savedCompletionReview.data : null);
3984
- this.backgroundVerification.clear();
3450
+ this.verificationTracker.clearBackground();
3985
3451
  const savedVerification = [...loaded.entries]
3986
3452
  .reverse()
3987
3453
  .find((entry) => entry.type === "custom" && entry.kind === VERIFICATION_STATE_KIND);
@@ -4011,12 +3477,8 @@ export class AgentSession {
4011
3477
  // Restore Nolan's advisory turns (custom entries, not on the message branch) so
4012
3478
  // they reappear in the transcript and survive into the continuation file.
4013
3479
  // The leaf lets marker replay retain its independent file-order position.
4014
- this.nolanTurns = this.sessionManager.getNolanTurns(loaded.entries, loaded.header.leafId);
4015
- // Restore autopilot verdict markers the same way (not on the message DAG).
4016
- this.autopilotMarkers = this.sessionManager.getAutopilotMarkers(loaded.entries, loaded.header.leafId);
4017
- // Restore app transcript markers (plan banner / task header / errors / hints).
4018
- this.appMarkers = this.sessionManager.getAppMarkers(loaded.entries, loaded.header.leafId);
4019
- this.turnMetrics = this.sessionManager.getTurnMetrics(loaded.entries);
3480
+ // Autopilot verdicts, app markers and turn metrics are restored the same way.
3481
+ this.markers.restore(loaded.entries, loaded.header.leafId);
4020
3482
  // A run that opened the journal and never closed it died mid-flight. Read
4021
3483
  // it here, before anything rewrites the file, and report it once the
4022
3484
  // transcript is in place.
@@ -4216,13 +3678,4 @@ export class AgentSession {
4216
3678
  }
4217
3679
  }
4218
3680
  }
4219
- /** Expand a leading `~` so `/import ~/.codex/...` works from any shell. */
4220
- function resolveHomePath(filePath) {
4221
- if (filePath === "~")
4222
- return os.homedir();
4223
- if (filePath.startsWith("~/") || filePath.startsWith("~\\")) {
4224
- return path.join(os.homedir(), filePath.slice(2));
4225
- }
4226
- return filePath;
4227
- }
4228
3681
  //# sourceMappingURL=agent-session.js.map