@prestyj/cli 5.29.1 → 5.30.1

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 (284) hide show
  1. package/README.md +11 -12
  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/compliance-guard/references/us.md +1 -1
  7. package/assets/skills/durable/SKILL.md +1 -1
  8. package/assets/skills/evidence-led-ui/SKILL.md +1 -1
  9. package/assets/skills/lean/SKILL.md +1 -1
  10. package/assets/skills/refactoring/SKILL.md +3 -1
  11. package/assets/skills/root-cause/SKILL.md +1 -1
  12. package/assets/skills/shared-language/SKILL.md +1 -1
  13. package/assets/skills/tdd/SKILL.md +1 -1
  14. package/dist/app-error.d.ts +21 -0
  15. package/dist/app-error.js +39 -0
  16. package/dist/app-error.js.map +1 -0
  17. package/dist/app-sidecar/app-settings.d.ts +63 -0
  18. package/dist/app-sidecar/app-settings.js +147 -0
  19. package/dist/app-sidecar/app-settings.js.map +1 -0
  20. package/dist/app-sidecar/attachments.d.ts +11 -0
  21. package/dist/app-sidecar/attachments.js +47 -0
  22. package/dist/app-sidecar/attachments.js.map +1 -0
  23. package/dist/app-sidecar/auth-routes.d.ts +3 -0
  24. package/dist/app-sidecar/auth-routes.js +290 -0
  25. package/dist/app-sidecar/auth-routes.js.map +1 -0
  26. package/dist/app-sidecar/autopilot-runner.d.ts +62 -0
  27. package/dist/app-sidecar/autopilot-runner.js +376 -0
  28. package/dist/app-sidecar/autopilot-runner.js.map +1 -0
  29. package/dist/app-sidecar/file-search.d.ts +15 -0
  30. package/dist/app-sidecar/file-search.js +103 -0
  31. package/dist/app-sidecar/file-search.js.map +1 -0
  32. package/dist/app-sidecar/hf-pull-manager.d.ts +20 -0
  33. package/dist/app-sidecar/hf-pull-manager.js +224 -0
  34. package/dist/app-sidecar/hf-pull-manager.js.map +1 -0
  35. package/dist/app-sidecar/history-routes.d.ts +3 -0
  36. package/dist/app-sidecar/history-routes.js +375 -0
  37. package/dist/app-sidecar/history-routes.js.map +1 -0
  38. package/dist/app-sidecar/history.d.ts +67 -0
  39. package/dist/app-sidecar/history.js +23 -0
  40. package/dist/app-sidecar/history.js.map +1 -0
  41. package/dist/app-sidecar/http.d.ts +5 -0
  42. package/dist/app-sidecar/http.js +27 -0
  43. package/dist/app-sidecar/http.js.map +1 -0
  44. package/dist/app-sidecar/integration-routes.d.ts +3 -0
  45. package/dist/app-sidecar/integration-routes.js +286 -0
  46. package/dist/app-sidecar/integration-routes.js.map +1 -0
  47. package/dist/app-sidecar/json-mode.d.ts +10 -0
  48. package/dist/app-sidecar/json-mode.js +73 -0
  49. package/dist/app-sidecar/json-mode.js.map +1 -0
  50. package/dist/app-sidecar/keep-awake.d.ts +4 -0
  51. package/dist/app-sidecar/keep-awake.js +5 -0
  52. package/dist/app-sidecar/keep-awake.js.map +1 -0
  53. package/dist/app-sidecar/mcp-routes.d.ts +3 -0
  54. package/dist/app-sidecar/mcp-routes.js +185 -0
  55. package/dist/app-sidecar/mcp-routes.js.map +1 -0
  56. package/dist/app-sidecar/mcp-rows.d.ts +31 -0
  57. package/dist/app-sidecar/mcp-rows.js +77 -0
  58. package/dist/app-sidecar/mcp-rows.js.map +1 -0
  59. package/dist/app-sidecar/nolan-context.d.ts +20 -0
  60. package/dist/app-sidecar/nolan-context.js +55 -0
  61. package/dist/app-sidecar/nolan-context.js.map +1 -0
  62. package/dist/app-sidecar/progress-manager.d.ts +10 -0
  63. package/dist/app-sidecar/progress-manager.js +116 -0
  64. package/dist/app-sidecar/progress-manager.js.map +1 -0
  65. package/dist/app-sidecar/prompt-routes.d.ts +3 -0
  66. package/dist/app-sidecar/prompt-routes.js +355 -0
  67. package/dist/app-sidecar/prompt-routes.js.map +1 -0
  68. package/dist/app-sidecar/repo-polls.d.ts +15 -0
  69. package/dist/app-sidecar/repo-polls.js +25 -0
  70. package/dist/app-sidecar/repo-polls.js.map +1 -0
  71. package/dist/app-sidecar/route-context.d.ts +145 -0
  72. package/dist/app-sidecar/route-context.js +2 -0
  73. package/dist/app-sidecar/route-context.js.map +1 -0
  74. package/dist/app-sidecar/run-control-routes.d.ts +3 -0
  75. package/dist/app-sidecar/run-control-routes.js +437 -0
  76. package/dist/app-sidecar/run-control-routes.js.map +1 -0
  77. package/dist/app-sidecar/session-routes.d.ts +3 -0
  78. package/dist/app-sidecar/session-routes.js +115 -0
  79. package/dist/app-sidecar/session-routes.js.map +1 -0
  80. package/dist/app-sidecar/session-types.d.ts +39 -0
  81. package/dist/app-sidecar/session-types.js +5 -0
  82. package/dist/app-sidecar/session-types.js.map +1 -0
  83. package/dist/app-sidecar/task-routes.d.ts +3 -0
  84. package/dist/app-sidecar/task-routes.js +232 -0
  85. package/dist/app-sidecar/task-routes.js.map +1 -0
  86. package/dist/app-sidecar/usage.d.ts +19 -0
  87. package/dist/app-sidecar/usage.js +153 -0
  88. package/dist/app-sidecar/usage.js.map +1 -0
  89. package/dist/app-sidecar/workspace-routes.d.ts +3 -0
  90. package/dist/app-sidecar/workspace-routes.js +374 -0
  91. package/dist/app-sidecar/workspace-routes.js.map +1 -0
  92. package/dist/app-sidecar.js +518 -3978
  93. package/dist/app-sidecar.js.map +1 -1
  94. package/dist/cli.js +6 -2
  95. package/dist/cli.js.map +1 -1
  96. package/dist/config.js +1 -1
  97. package/dist/core/acceptance-checks.js +3 -9
  98. package/dist/core/acceptance-checks.js.map +1 -1
  99. package/dist/core/agent-session/helpers.d.ts +26 -0
  100. package/dist/core/agent-session/helpers.js +47 -0
  101. package/dist/core/agent-session/helpers.js.map +1 -0
  102. package/dist/core/agent-session/mcp-tools.d.ts +68 -0
  103. package/dist/core/agent-session/mcp-tools.js +195 -0
  104. package/dist/core/agent-session/mcp-tools.js.map +1 -0
  105. package/dist/core/agent-session/run-loop-monitor.d.ts +63 -0
  106. package/dist/core/agent-session/run-loop-monitor.js +164 -0
  107. package/dist/core/agent-session/run-loop-monitor.js.map +1 -0
  108. package/dist/core/agent-session/transcript-markers.d.ts +57 -0
  109. package/dist/core/agent-session/transcript-markers.js +166 -0
  110. package/dist/core/agent-session/transcript-markers.js.map +1 -0
  111. package/dist/core/agent-session/verification-tracker.d.ts +46 -0
  112. package/dist/core/agent-session/verification-tracker.js +250 -0
  113. package/dist/core/agent-session/verification-tracker.js.map +1 -0
  114. package/dist/core/agent-session.d.ts +29 -89
  115. package/dist/core/agent-session.js +167 -705
  116. package/dist/core/agent-session.js.map +1 -1
  117. package/dist/core/auth-providers.js +3 -3
  118. package/dist/core/auth-providers.js.map +1 -1
  119. package/dist/core/autopilot-cycle.d.ts +6 -2
  120. package/dist/core/autopilot-cycle.js +1 -1
  121. package/dist/core/autopilot-cycle.js.map +1 -1
  122. package/dist/core/bundled-agents.js +6 -6
  123. package/dist/core/bundled-agents.js.map +1 -1
  124. package/dist/core/checklist-detection.d.ts +11 -0
  125. package/dist/core/checklist-detection.js +238 -0
  126. package/dist/core/checklist-detection.js.map +1 -0
  127. package/dist/core/checklist-git.d.ts +19 -0
  128. package/dist/core/checklist-git.js +54 -0
  129. package/dist/core/checklist-git.js.map +1 -0
  130. package/dist/core/checklist-items.d.ts +189 -0
  131. package/dist/core/checklist-items.js +226 -0
  132. package/dist/core/checklist-items.js.map +1 -0
  133. package/dist/core/checklist-prompt.d.ts +7 -0
  134. package/dist/core/checklist-prompt.js +45 -0
  135. package/dist/core/checklist-prompt.js.map +1 -0
  136. package/dist/core/checklist-snapshot.d.ts +17 -0
  137. package/dist/core/checklist-snapshot.js +46 -0
  138. package/dist/core/checklist-snapshot.js.map +1 -0
  139. package/dist/core/checklist-store.d.ts +59 -0
  140. package/dist/core/checklist-store.js +242 -0
  141. package/dist/core/checklist-store.js.map +1 -0
  142. package/dist/core/context-limits.js +1 -1
  143. package/dist/core/context-limits.js.map +1 -1
  144. package/dist/core/lsp/client.d.ts +4 -0
  145. package/dist/core/lsp/client.js +71 -3
  146. package/dist/core/lsp/client.js.map +1 -1
  147. package/dist/core/lsp/manager.d.ts +1 -0
  148. package/dist/core/lsp/manager.js +9 -3
  149. package/dist/core/lsp/manager.js.map +1 -1
  150. package/dist/core/lsp/project-root.d.ts +9 -0
  151. package/dist/core/lsp/project-root.js +75 -0
  152. package/dist/core/lsp/project-root.js.map +1 -0
  153. package/dist/core/lsp/reference-project.d.ts +6 -0
  154. package/dist/core/lsp/reference-project.js +110 -0
  155. package/dist/core/lsp/reference-project.js.map +1 -0
  156. package/dist/core/lsp/servers.js +1 -0
  157. package/dist/core/lsp/servers.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/prompt-commands.js +95 -0
  165. package/dist/core/prompt-commands.js.map +1 -1
  166. package/dist/core/skills.js +6 -9
  167. package/dist/core/skills.js.map +1 -1
  168. package/dist/core/style-packs/index.js +2 -11
  169. package/dist/core/style-packs/index.js.map +1 -1
  170. package/dist/core/style-packs/packs.d.ts +3 -12
  171. package/dist/core/style-packs/packs.js +132 -173
  172. package/dist/core/style-packs/packs.js.map +1 -1
  173. package/dist/core/subagent-manager.d.ts +2 -1
  174. package/dist/core/subagent-manager.js +8 -5
  175. package/dist/core/subagent-manager.js.map +1 -1
  176. package/dist/core/verification-evidence.d.ts +8 -0
  177. package/dist/core/verification-evidence.js +27 -5
  178. package/dist/core/verification-evidence.js.map +1 -1
  179. package/dist/modes/acp-mode.d.ts +2 -1
  180. package/dist/modes/acp-mode.js +4 -1
  181. package/dist/modes/acp-mode.js.map +1 -1
  182. package/dist/modes/subagent-worker-mode.d.ts +2 -0
  183. package/dist/modes/subagent-worker-mode.js +1 -3
  184. package/dist/modes/subagent-worker-mode.js.map +1 -1
  185. package/dist/system-prompt.js +52 -78
  186. package/dist/system-prompt.js.map +1 -1
  187. package/dist/tools/ask-user.js +13 -74
  188. package/dist/tools/ask-user.js.map +1 -1
  189. package/dist/tools/bash.d.ts +4 -1
  190. package/dist/tools/bash.js +46 -81
  191. package/dist/tools/bash.js.map +1 -1
  192. package/dist/tools/checklist.d.ts +48 -0
  193. package/dist/tools/checklist.js +146 -0
  194. package/dist/tools/checklist.js.map +1 -0
  195. package/dist/tools/code-nav.d.ts +1 -1
  196. package/dist/tools/code-nav.js +9 -30
  197. package/dist/tools/code-nav.js.map +1 -1
  198. package/dist/tools/edit.d.ts +3 -9
  199. package/dist/tools/edit.js +92 -65
  200. package/dist/tools/edit.js.map +1 -1
  201. package/dist/tools/enter-plan.js +2 -7
  202. package/dist/tools/enter-plan.js.map +1 -1
  203. package/dist/tools/exit-plan.d.ts +2 -2
  204. package/dist/tools/exit-plan.js +9 -4
  205. package/dist/tools/exit-plan.js.map +1 -1
  206. package/dist/tools/find.js +3 -4
  207. package/dist/tools/find.js.map +1 -1
  208. package/dist/tools/grep.js +6 -18
  209. package/dist/tools/grep.js.map +1 -1
  210. package/dist/tools/index.d.ts +5 -3
  211. package/dist/tools/index.js +5 -1
  212. package/dist/tools/index.js.map +1 -1
  213. package/dist/tools/ls.js +3 -3
  214. package/dist/tools/ls.js.map +1 -1
  215. package/dist/tools/prompt-hints.js +35 -46
  216. package/dist/tools/prompt-hints.js.map +1 -1
  217. package/dist/tools/read.js +8 -20
  218. package/dist/tools/read.js.map +1 -1
  219. package/dist/tools/search-code.js +4 -12
  220. package/dist/tools/search-code.js.map +1 -1
  221. package/dist/tools/skill.js +13 -7
  222. package/dist/tools/skill.js.map +1 -1
  223. package/dist/tools/subagent-control.js +7 -22
  224. package/dist/tools/subagent-control.js.map +1 -1
  225. package/dist/tools/subagent-shared.d.ts +6 -8
  226. package/dist/tools/subagent-shared.js +17 -11
  227. package/dist/tools/subagent-shared.js.map +1 -1
  228. package/dist/tools/subagent.d.ts +2 -1
  229. package/dist/tools/subagent.js +7 -7
  230. package/dist/tools/subagent.js.map +1 -1
  231. package/dist/tools/task-output.js +4 -15
  232. package/dist/tools/task-output.js.map +1 -1
  233. package/dist/tools/task-send.js +5 -17
  234. package/dist/tools/task-send.js.map +1 -1
  235. package/dist/tools/task-stop.js +2 -2
  236. package/dist/tools/task-stop.js.map +1 -1
  237. package/dist/tools/tasks.d.ts +1 -1
  238. package/dist/tools/tool-search.js +2 -7
  239. package/dist/tools/tool-search.js.map +1 -1
  240. package/dist/tools/tool-tiers.d.ts +21 -4
  241. package/dist/tools/tool-tiers.js +45 -17
  242. package/dist/tools/tool-tiers.js.map +1 -1
  243. package/dist/tools/verification-review.d.ts +2 -0
  244. package/dist/tools/verification-review.js +41 -0
  245. package/dist/tools/verification-review.js.map +1 -0
  246. package/dist/tools/web-fetch.js +7 -33
  247. package/dist/tools/web-fetch.js.map +1 -1
  248. package/dist/tools/write.js +3 -4
  249. package/dist/tools/write.js.map +1 -1
  250. package/dist/ui/App.js +1 -0
  251. package/dist/ui/App.js.map +1 -1
  252. package/dist/ui/components/Footer.js +1 -2
  253. package/dist/ui/components/Footer.js.map +1 -1
  254. package/dist/ui/hooks/useModeState.js +15 -0
  255. package/dist/ui/hooks/useModeState.js.map +1 -1
  256. package/dist/ui/tool-line-summary.js +11 -1
  257. package/dist/ui/tool-line-summary.js.map +1 -1
  258. package/dist/ui/transcript/tool-presentation.js +1 -1
  259. package/dist/ui/transcript/tool-presentation.js.map +1 -1
  260. package/dist/utils/shared-poll.d.ts +37 -0
  261. package/dist/utils/shared-poll.js +104 -0
  262. package/dist/utils/shared-poll.js.map +1 -0
  263. package/package.json +8 -5
  264. package/dist/core/api-benchmark.d.ts +0 -63
  265. package/dist/core/api-benchmark.js +0 -381
  266. package/dist/core/api-benchmark.js.map +0 -1
  267. package/dist/core/bash-spawn-benchmark.d.ts +0 -1
  268. package/dist/core/bash-spawn-benchmark.js +0 -139
  269. package/dist/core/bash-spawn-benchmark.js.map +0 -1
  270. package/dist/core/cache-warm-benchmark.d.ts +0 -21
  271. package/dist/core/cache-warm-benchmark.js +0 -170
  272. package/dist/core/cache-warm-benchmark.js.map +0 -1
  273. package/dist/core/fast-apply-benchmark.d.ts +0 -25
  274. package/dist/core/fast-apply-benchmark.js +0 -284
  275. package/dist/core/fast-apply-benchmark.js.map +0 -1
  276. package/dist/core/hashline-edit-benchmark.d.ts +0 -88
  277. package/dist/core/hashline-edit-benchmark.js +0 -518
  278. package/dist/core/hashline-edit-benchmark.js.map +0 -1
  279. package/dist/core/semantic-search-benchmark.d.ts +0 -36
  280. package/dist/core/semantic-search-benchmark.js +0 -211
  281. package/dist/core/semantic-search-benchmark.js.map +0 -1
  282. package/dist/core/speed-benchmark.d.ts +0 -132
  283. package/dist/core/speed-benchmark.js +0 -410
  284. package/dist/core/speed-benchmark.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 } 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,12 +371,13 @@ 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.
445
378
  getProvider: () => this.provider,
446
379
  getModel: () => this.model,
380
+ getThinkingLevel: () => this.thinkingLevel,
447
381
  getBaseUrl: () => this.baseUrl,
448
382
  getCacheKey: () => this.getPromptCacheKey(),
449
383
  getMaxPerModel: () => this.settingsManager.get("subagentMaxPerModel"),
@@ -484,6 +418,10 @@ export class AgentSession {
484
418
  // every request. Allow-listed sessions keep the eager path — their fixed
485
419
  // tool expectations predate the catalog, and tool_search isn't allow-listed.
486
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();
487
425
  const { core, deferred } = partitionToolsByTier(this.tools);
488
426
  if (deferred.length > 0) {
489
427
  // Append-only: `core` preserves the original relative order and
@@ -491,10 +429,7 @@ export class AgentSession {
491
429
  // sits inside the cached prefix stays byte-stable across turns.
492
430
  this.tools = core;
493
431
  this.deferredBuiltinToolNames = deferred.map((t) => t.name);
494
- this.mcpCatalog ??= new DeferredToolCatalog(this.contextLimits);
495
- this.mcpCatalog.add(deferred);
496
- this.ensureToolSearchTool();
497
- this.promoteWaitAgentAfterSpawn();
432
+ this.mcp.addDeferred(deferred);
498
433
  }
499
434
  }
500
435
  this.rebuildReadTool = rebuildReadTool;
@@ -509,8 +444,7 @@ export class AgentSession {
509
444
  // Connect MCP servers. Child sessions skip user-configured servers to avoid
510
445
  // cold-spawning every server per short-lived worker. Background connection
511
446
  // keeps slow first-run stdio servers off the host readiness path.
512
- this.mcpManager = new MCPClientManager({
513
- catalogCache: this.mcpCatalogCache,
447
+ this.mcp.createManager({
514
448
  modernProtocol: this.settingsManager.get("mcpModernProtocol"),
515
449
  onElicit: this.opts.onMcpElicit,
516
450
  });
@@ -659,7 +593,8 @@ export class AgentSession {
659
593
  * turn, so background-connected servers become available on the next prompt.
660
594
  */
661
595
  async connectMcpServers() {
662
- if (!this.mcpManager)
596
+ const mcpManager = this.mcp.manager;
597
+ if (!mcpManager)
663
598
  return;
664
599
  // Allow-listed (read-only advisory) sessions enforce a fixed tool set by
665
600
  // name. An MCP server is only connected when its name is explicitly
@@ -695,7 +630,7 @@ export class AgentSession {
695
630
  // empty catalog and tool_search would answer "the catalog is empty" for
696
631
  // capabilities that genuinely exist — a wrong answer, not a slow one.
697
632
  await this.seedMcpCatalogFromCache(servers);
698
- const connected = await this.mcpManager.connectAll(servers);
633
+ const connected = await mcpManager.connectAll(servers);
699
634
  // Defense-in-depth: even from a whitelisted server, only push tools that
700
635
  // pass the allow-list (no-op when there's no allow-list).
701
636
  const mcpTools = this.opts.allowedTools
@@ -734,170 +669,65 @@ export class AgentSession {
734
669
  * pushed eagerly when the user opted out.
735
670
  * Allow-listed sessions (Nolan) always get the eager path — their fixed tool
736
671
  * expectations predate the catalog, and tool_search isn't allow-listed.
737
- * Promotion pushes onto the live `this.tools` array the running agent loop
738
- * re-reads every turn, so promoted tools are callable on the next step.
739
672
  */
740
673
  addMcpTools(mcpTools) {
741
- if (mcpTools.length === 0)
742
- return;
743
- for (const tool of mcpTools) {
744
- this.liveMcpTools.set(tool.name, tool);
745
- this.cachedMcpToolServers.delete(tool.name);
746
- }
747
674
  const defer = !this.opts.allowedTools && this.settingsManager.get("deferredMcpTools");
748
- if (!defer) {
749
- // Eager path bypasses the catalog, so budget descriptions here too.
750
- this.replaceOrPushTools(mcpTools.map((tool) => clampMcpToolDescription(tool, this.contextLimits)));
751
- return;
752
- }
753
- this.mcpCatalog ??= new DeferredToolCatalog(this.contextLimits);
754
- // `add` is name-keyed, so live definitions replace cached stubs in place.
755
- this.mcpCatalog.add(mcpTools);
756
- // A stub the model already promoted lives in `this.tools`; swap it for the
757
- // live tool so later calls dispatch directly instead of through the stub.
758
- this.replaceLivePromotedTools(mcpTools);
759
- this.ensureToolSearchTool();
675
+ this.mcp.addMcpTools(mcpTools, defer);
760
676
  }
761
677
  /**
762
- * Register `tool_search` once. Promotion of a cached-only entry waits for its
763
- * server so the model is told immediately when that capability turns out to
764
- * be unreachable, instead of promoting a tool that fails on first call.
765
- *
766
- * The catalog is created on demand rather than required up front: deferred
767
- * built-in tools populate it with zero MCP servers connected, so gating
768
- * 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.
769
686
  */
770
- ensureToolSearchTool() {
771
- this.mcpCatalog ??= new DeferredToolCatalog(this.contextLimits);
772
- if (this.tools.some((t) => t.name === "tool_search"))
773
- return;
774
- this.tools.push(createToolSearchTool(this.mcpCatalog, (promoted) => {
775
- this.tools.push(...promoted);
776
- }, async (toolName) => {
777
- if (this.liveMcpTools.has(toolName))
778
- return undefined;
779
- const serverName = this.cachedMcpToolServers.get(toolName);
780
- if (!serverName)
781
- return undefined;
782
- const outcome = (await this.mcpManager?.whenConnected(serverName)) ?? {
783
- ok: false,
784
- error: "MCP is disabled for this session",
785
- };
786
- return outcome.ok
787
- ? { serverName, ok: true }
788
- : { serverName, ok: false, error: outcome.error };
789
- }, 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);
790
690
  }
791
- /**
792
- * `wait_agent` is deferred, yet nearly every `spawn_agent` is followed by it,
793
- * so the model spent a whole turn on `tool_search` just to load it (bench 41:
794
- * ~6 s per fan-out). Promote it as soon as a spawn succeeds instead: the tool
795
- * list grows exactly as it would after that `tool_search`, one turn earlier,
796
- * and sessions that never spawn keep the smaller prefix.
797
- */
798
- promoteWaitAgentAfterSpawn() {
799
- const index = this.tools.findIndex((t) => t.name === "spawn_agent");
800
- const spawn = index >= 0 ? this.tools[index] : undefined;
801
- 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)
802
707
  return;
803
708
  this.tools[index] = {
804
- ...spawn,
709
+ ...tool,
805
710
  execute: async (args, context) => {
806
- const result = await spawn.execute(args, context);
807
- if (!this.tools.some((t) => t.name === "wait_agent")) {
808
- 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));
809
716
  }
810
717
  return result;
811
718
  },
812
719
  };
813
720
  }
814
- /** Append tools, replacing any same-named entry (cached stub → live tool). */
815
- replaceOrPushTools(tools) {
816
- for (const tool of tools) {
817
- const index = this.tools.findIndex((t) => t.name === tool.name);
818
- if (index >= 0)
819
- this.tools[index] = tool;
820
- else
821
- this.tools.push(tool);
822
- }
823
- }
824
- /** Swap already-promoted cached stubs for their live equivalents, in place. */
825
- replaceLivePromotedTools(tools) {
826
- for (const tool of tools) {
827
- const index = this.tools.findIndex((t) => t.name === tool.name);
828
- if (index >= 0)
829
- this.tools[index] = tool;
830
- }
831
- }
832
721
  /**
833
722
  * Publish cached tool definitions into the deferred catalog so `tool_search`
834
- * answers correctly on turn 1. A cached stub carries the real name, one-line
835
- * description and input schema; calling it waits for the live connection and
836
- * then dispatches against the real client, or returns a clear error when that
837
- * server ultimately failed. Live tools replace stubs on connect.
723
+ * answers correctly on turn 1 (see {@link McpToolRegistry.seedFromCache}).
838
724
  */
839
725
  async seedMcpCatalogFromCache(servers) {
840
726
  if (!this.opts.backgroundMcpConnect)
841
727
  return;
842
728
  if (this.opts.allowedTools || !this.settingsManager.get("deferredMcpTools"))
843
729
  return;
844
- let entries;
845
- try {
846
- entries = await this.mcpCatalogCache.entriesFor(servers);
847
- }
848
- catch {
849
- return;
850
- }
851
- const stubs = [];
852
- for (const [serverName, entry] of entries) {
853
- for (const cached of entry.tools) {
854
- if (this.liveMcpTools.has(cached.name))
855
- continue;
856
- this.cachedMcpToolServers.set(cached.name, serverName);
857
- stubs.push(this.buildCachedMcpTool(serverName, cached));
858
- }
859
- }
860
- if (stubs.length === 0)
861
- return;
862
- log("INFO", "mcp", "Seeded deferred tool catalog from cache", {
863
- tools: String(stubs.length),
864
- servers: String(entries.size),
865
- });
866
- this.addCachedMcpTools(stubs);
867
- }
868
- /** Catalog-only registration for cached stubs — never marks them live. */
869
- addCachedMcpTools(stubs) {
870
- this.mcpCatalog ??= new DeferredToolCatalog(this.contextLimits);
871
- this.mcpCatalog.add(stubs);
872
- this.ensureToolSearchTool();
873
- }
874
- buildCachedMcpTool(serverName, cached) {
875
- return {
876
- name: cached.name,
877
- description: cached.description,
878
- parameters: z.record(z.string(), z.unknown()),
879
- rawInputSchema: cached.rawInputSchema,
880
- execute: async (args, context) => {
881
- const live = this.liveMcpTools.get(cached.name);
882
- if (live)
883
- return live.execute(args, context);
884
- const outcome = (await this.mcpManager?.whenConnected(serverName)) ?? {
885
- ok: false,
886
- error: "MCP is disabled for this session",
887
- };
888
- if (!outcome.ok) {
889
- return (`MCP tool ${cached.name} is unavailable: server "${serverName}" did not connect ` +
890
- `(${outcome.error}). This tool was offered from a cached catalog. ` +
891
- `Use a different approach or ask the user to check their MCP configuration.`);
892
- }
893
- const connected = this.liveMcpTools.get(cached.name);
894
- if (!connected) {
895
- return (`MCP tool ${cached.name} no longer exists: server "${serverName}" connected but ` +
896
- `does not expose it. The cached catalog entry was stale.`);
897
- }
898
- return connected.execute(args, context);
899
- },
900
- };
730
+ await this.mcp.seedFromCache(servers);
901
731
  }
902
732
  /**
903
733
  * Resolve a `/name [args]` input to the prompt template it expands into, or
@@ -1084,33 +914,15 @@ export class AgentSession {
1084
914
  resetHookState(originalRequest) {
1085
915
  this.opts.completionReview?.begin(originalRequest);
1086
916
  this.lspManager?.clearPendingDiagnostics();
1087
- this.hookStats = { toolCalls: 0, toolFailures: 0, turns: 0 };
1088
- this.hookText = "";
1089
- this.hookConsecutiveFailures = 0;
1090
- this.hookRepeatedNoProgressCalls = 0;
1091
- this.hookProgressTracker.reset();
1092
- this.hookCycleDetector.reset();
1093
- this.hookCyclicPattern = null;
1094
- this.hookFileEditCounts.clear();
1095
- this.hookToolCalls.clear();
1096
- this.loopBreakInjected = 0;
917
+ this.loopMonitor.reset();
918
+ this.verificationTracker.resetRun();
1097
919
  this.regroundingInjected = false;
1098
- this.hookRecentCalls = [];
1099
- this.semanticLoop = {
1100
- checksUsed: 0,
1101
- lastCheckTurn: 0,
1102
- pending: false,
1103
- verdict: null,
1104
- injected: false,
1105
- };
1106
920
  this.runStartedAt = Date.now();
1107
921
  this.processGateInjected = 0;
922
+ this.planSubmissionNudges = 0;
1108
923
  this.verificationGate.beginRun();
1109
924
  const processes = new Set(this.processManager?.list().map((p) => p.id) ?? []);
1110
- for (const id of this.backgroundVerification.keys()) {
1111
- if (!processes.has(id))
1112
- this.backgroundVerification.delete(id);
1113
- }
925
+ this.verificationTracker.pruneBackground(processes);
1114
926
  this.compactionOccurred = false;
1115
927
  // Post-turn compaction may have landed between runs — adopt its armed
1116
928
  // signal so the re-grounding hook fires for this run exactly as it would
@@ -1134,153 +946,20 @@ export class AgentSession {
1134
946
  }
1135
947
  switch (event.type) {
1136
948
  case "text_delta":
1137
- this.hookText += event.text;
949
+ this.loopMonitor.recordText(event.text);
1138
950
  break;
1139
951
  case "tool_call_start": {
1140
- this.hookToolCalls.set(event.toolCallId, {
1141
- name: event.name,
1142
- args: event.args ?? {},
1143
- revision: this.verificationGate.revision,
1144
- });
1145
- const startClassification = event.name === "bash" && typeof event.args?.command === "string"
1146
- ? classifyVerificationCommand(event.args.command)
1147
- : null;
1148
- if (startClassification &&
1149
- typeof event.args?.command === "string" &&
1150
- (isVerificationCommand(event.args.command) ||
1151
- startClassification.accepted ||
1152
- // Must match the tool_call_end predicate: a snapshot-eligible
1153
- // command that never captured a "before" snapshot is misread at
1154
- // the end as an uncomparable workspace and re-arms the gate.
1155
- startClassification.snapshotEligible === true)) {
1156
- // A check that can rewrite files (--fix, build scripts, emitters)
1157
- // invalidates earlier in-flight evidence AND marks the run as
1158
- // touched. A check that is merely UNRECOGNIZED (`make test`, `deno
1159
- // test`) rewrites nothing we can point to: bumping the revision for
1160
- // it poisoned the gate on green output and re-armed the hook into
1161
- // every later question turn.
1162
- this.verificationGate.recordVerificationAttempt();
1163
- const classification = startClassification;
1164
- if (classification.snapshotEligible && event.args.persist !== true) {
1165
- const call = this.hookToolCalls.get(event.toolCallId);
1166
- call.sourceSnapshot = await captureVerificationSnapshot(this.opts.cwd, [
1167
- ...this.hookFileEditCounts.keys(),
1168
- ]);
1169
- if (call.sourceSnapshot === null)
1170
- this.verificationGate.requireFreshVerification(true, event.args.command);
1171
- }
1172
- else if ((classification.accepted && event.args.persist !== true) ||
1173
- (!classification.accepted && classification.mayMutate)) {
1174
- // Flag the workspace unknown only when tool_call_end can resolve
1175
- // it: a bounded check records pass/fail, a file-rewriting command
1176
- // bumps the revision. An unrecognized read-only check (`biome ci`)
1177
- // or a persistent-shell run records nothing at the end, so
1178
- // flagging it left verified work Unverified forever — and
1179
- // autopilot silently refused every later turn.
1180
- this.verificationGate.requireFreshVerification(!classification.accepted && classification.mayMutate, event.args.command);
1181
- }
952
+ if (await this.verificationTracker.recordToolCallStart(event)) {
1182
953
  await this.persistVerificationState();
1183
954
  }
1184
955
  break;
1185
956
  }
1186
957
  case "tool_call_end": {
1187
- const call = this.hookToolCalls.get(event.toolCallId);
958
+ const call = this.verificationTracker.getToolCall(event.toolCallId);
1188
959
  const name = call?.name ?? "";
1189
960
  const args = call?.args;
1190
- this.hookStats.toolCalls += 1;
1191
- if (event.isError)
1192
- this.hookStats.toolFailures += 1;
1193
- this.hookConsecutiveFailures = event.isError ? this.hookConsecutiveFailures + 1 : 0;
1194
- this.hookRepeatedNoProgressCalls = this.hookProgressTracker.record(name, args, event.result, event.isError);
1195
- this.hookCyclicPattern = this.hookCycleDetector.record(name, args, event.result, event.isError);
1196
- // Semantic-loop judge input: a bounded digest of WHAT was attempted and
1197
- // HOW it came out. Args/results are sliced AT RECORD TIME — a write with
1198
- // a 50 KiB payload or a bash dump must never inflate the ring, and the
1199
- // judge needs shapes, not payloads.
1200
- this.hookRecentCalls.push({
1201
- tool: name,
1202
- args: args === undefined ? "" : JSON.stringify(args).slice(0, 300),
1203
- ok: !event.isError,
1204
- result: event.result.slice(0, 400),
1205
- });
1206
- if (this.hookRecentCalls.length > MAX_SEMANTIC_LOOP_CALLS) {
1207
- this.hookRecentCalls.splice(0, this.hookRecentCalls.length - MAX_SEMANTIC_LOOP_CALLS);
1208
- }
1209
- // Only host-observed successful mutations and trustworthy check results
1210
- // affect approval. The model's text is never evidence.
1211
- let verificationChanged = false;
1212
- if (!event.isError && args) {
1213
- if (name === "edit" || name === "write") {
1214
- // Check-owning files (tsconfig.json, pytest.ini, vitest.config.ts …)
1215
- // are tracked even when they are not source code: editing one is how
1216
- // a red suite is turned green without fixing anything. A multi-file
1217
- // edit records every file; its combined diff is scanned for each.
1218
- const addedText = name === "write"
1219
- ? String(args.content ?? "")
1220
- : extractAddedLines(event.details?.diff ?? event.result);
1221
- for (const filePath of editTargetPaths(args)) {
1222
- if (isCodeFilePath(filePath) || isCheckOwnFile(filePath)) {
1223
- this.verificationGate.recordMutation(filePath, addedText);
1224
- verificationChanged = true;
1225
- }
1226
- }
1227
- }
1228
- }
1229
- if (args && call && name === "bash") {
1230
- const command = typeof args.command === "string" ? args.command : "";
1231
- const classification = classifyVerificationCommand(command);
1232
- if (classification.accepted || classification.snapshotEligible) {
1233
- // A foreground check that outlived the default budget was moved to
1234
- // the background, not failed: track it to its real exit the same way.
1235
- const autoBackgroundId = event.isError ? undefined : autoBackgroundedId(event.result);
1236
- if ((autoBackgroundId !== undefined || args.run_in_background === true) &&
1237
- !event.isError &&
1238
- args.persist !== true) {
1239
- const id = autoBackgroundId ?? /^ID:\s*(\S+)/m.exec(event.result)?.[1];
1240
- // No parseable ID means the check cannot be tracked to a real exit
1241
- // code — no evidence either way. Recording a FAILURE here made
1242
- // every later green run of a different spelling look owed.
1243
- if (id)
1244
- this.backgroundVerification.set(id, {
1245
- revision: call.revision,
1246
- command,
1247
- ...(classification.snapshotEligible
1248
- ? { sourceSnapshot: call.sourceSnapshot ?? null }
1249
- : {}),
1250
- });
1251
- else if (classification.snapshotEligible)
1252
- this.verificationGate.requireFreshVerification(true, command);
1253
- }
1254
- else if (args.persist === true) {
1255
- // Persistent-shell checks are not bounded evidence (steering can
1256
- // interleave): neither a pass nor a failure. A recorded failure
1257
- // here blocked approval for sessions that prefer the shell.
1258
- }
1259
- else if (classification.snapshotEligible) {
1260
- await this.finishSnapshotVerification({ command, revision: call.revision, sourceSnapshot: call.sourceSnapshot ?? null }, !event.isError && /^Exit code:\s*0(?:\s|$)/i.test(event.result.trim()));
1261
- verificationChanged = true;
1262
- }
1263
- else {
1264
- if (!event.isError && /^Exit code:\s*0(?:\s|$)/i.test(event.result.trim())) {
1265
- this.verificationGate.recordVerification(call.revision, command);
1266
- }
1267
- else {
1268
- this.verificationGate.recordFailedVerification(command, call.revision);
1269
- }
1270
- verificationChanged = true;
1271
- }
1272
- delete call.sourceSnapshot;
1273
- }
1274
- else if (classification.candidate) {
1275
- // Green but untrusted: remember WHY so the demand can tell the
1276
- // agent which command shape actually clears the gate.
1277
- this.verificationGate.recordRejectedCheck(command, classification.reason);
1278
- }
1279
- }
1280
- if (!event.isError && args && name === "task_output" && typeof args.id === "string") {
1281
- verificationChanged =
1282
- (await this.recordFinishedBackgroundVerification(args.id)) || verificationChanged;
1283
- }
961
+ this.loopMonitor.recordToolEnd(name, args, event.result, event.isError);
962
+ const verificationChanged = await this.verificationTracker.recordToolCallEnd(event, call, this.planModeRef.current);
1284
963
  if (verificationChanged)
1285
964
  await this.persistVerificationState();
1286
965
  // Tool results are what push the run over the review gate, and they all
@@ -1290,7 +969,7 @@ export class AgentSession {
1290
969
  break;
1291
970
  }
1292
971
  case "turn_end":
1293
- this.hookStats.turns = event.turn;
972
+ this.loopMonitor.recordTurn(event.turn);
1294
973
  this.refreshHookArming();
1295
974
  for (let index = this.messages.length - 1; index >= 0; index--) {
1296
975
  const anchor = this.messages[index];
@@ -1467,51 +1146,26 @@ export class AgentSession {
1467
1146
  // Deterministic stuck verdict, computed once and shared: the semantic
1468
1147
  // judge must not spend tokens on a burst the deterministic breaker is
1469
1148
  // about to correct itself.
1470
- const deterministicDecision = evaluateLoopBreak({
1471
- consecutiveFailures: this.hookConsecutiveFailures,
1472
- repeatedNoProgressCalls: this.hookRepeatedNoProgressCalls,
1473
- textRepetitionDetected: detectTextRepetition(this.hookText),
1474
- ...(this.hookCyclicPattern ? { cyclicPattern: this.hookCyclicPattern } : {}),
1475
- });
1149
+ const deterministicDecision = this.loopMonitor.evaluateLoopBreak();
1476
1150
  this.maybeStartSemanticLoopCheck(deterministicDecision.shouldBreak);
1477
1151
  // Two-stage loop-breaker: stage 1 nudges; a FRESH detection after that
1478
1152
  // injects the harsher final stop-and-report prompt. Signals reset after
1479
1153
  // each injection so stage 2 only fires on new evidence.
1480
- if (this.loopBreakInjected < 2) {
1481
- const decision = deterministicDecision;
1482
- if (decision.shouldBreak) {
1483
- const stage = this.loopBreakInjected === 0 ? 1 : 2;
1484
- this.loopBreakInjected = stage;
1485
- this.hookProgressTracker.reset();
1486
- this.hookCycleDetector.reset();
1487
- this.hookCyclicPattern = null;
1488
- this.hookConsecutiveFailures = 0;
1489
- this.hookRepeatedNoProgressCalls = 0;
1490
- // The deterministic breaker owns this burst — a semantic verdict from
1491
- // the same burst must not double-correct on the next poll.
1492
- this.semanticLoop.verdict = null;
1493
- // Clear the text buffer too — otherwise a stage-1 text-repetition
1494
- // trigger still sees the same repeated tail on the next check and
1495
- // escalates to stage 2 on stale evidence.
1496
- this.hookText = "";
1497
- log("INFO", "loop-break", "Injecting loop-break nudge", {
1498
- stage: String(stage),
1499
- reasons: decision.reasons.join(", "),
1500
- });
1501
- this.eventBus.emit("hook", { kind: "loop_break" });
1502
- return [buildLoopBreakMessage(decision.reasons, stage === 2)];
1503
- }
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)];
1504
1162
  }
1505
1163
  // Semantic loop-break: an LLM verdict (started by maybeStartSemanticLoopCheck
1506
1164
  // on a suspicious-but-syntactically-quiet burst) is consumed here, exactly
1507
1165
  // once, with the deterministic breaker getting priority above. Fail-open:
1508
1166
  // no verdict or no-loop verdict injects nothing.
1509
- if (this.semanticLoop.verdict && !this.semanticLoop.injected) {
1510
- const verdict = this.semanticLoop.verdict;
1511
- this.semanticLoop.injected = true;
1512
- this.semanticLoop.verdict = null;
1513
- // The judged burst has been addressed; a fresh burst must re-accumulate.
1514
- this.hookConsecutiveFailures = 0;
1167
+ const verdict = this.loopMonitor.takeSemanticVerdict();
1168
+ if (verdict) {
1515
1169
  log("INFO", "loop-break", "Injecting semantic loop-break steering", {
1516
1170
  reason: verdict.reason,
1517
1171
  });
@@ -1531,34 +1185,26 @@ export class AgentSession {
1531
1185
  * Fire-and-forget: the call runs while the next turn streams, and a finished
1532
1186
  * verdict is consumed by the NEXT steering poll — never blocking a turn. */
1533
1187
  maybeStartSemanticLoopCheck(deterministicBreak) {
1534
- if (!shouldRunSemanticLoopCheck({
1535
- consecutiveFailures: this.hookConsecutiveFailures,
1536
- totalFailures: this.hookStats.toolFailures,
1537
- turns: this.hookStats.turns,
1538
- lastCheckTurn: this.semanticLoop.lastCheckTurn,
1539
- checksUsed: this.semanticLoop.checksUsed,
1540
- checkPending: this.semanticLoop.pending,
1541
- deterministicBreak,
1542
- })) {
1188
+ const monitor = this.loopMonitor;
1189
+ if (!monitor.shouldRunSemanticCheck(deterministicBreak)) {
1543
1190
  return;
1544
1191
  }
1545
- // resetHookState replaces this object on every prompt. A late judge must
1192
+ // resetHookState replaces the run state on every prompt. A late judge must
1546
1193
  // not publish a verdict or consume the next run's budget/cooldown.
1547
- const runState = this.semanticLoop;
1548
- runState.pending = true;
1194
+ const runState = monitor.beginSemanticCheck();
1549
1195
  log("INFO", "loop-break", "Starting semantic loop judge", {
1550
- turn: String(this.hookStats.turns),
1551
- consecutiveFailures: String(this.hookConsecutiveFailures),
1552
- recentCalls: String(this.hookRecentCalls.length),
1196
+ turn: String(monitor.stats.turns),
1197
+ consecutiveFailures: String(monitor.consecutiveFailures),
1198
+ recentCalls: String(monitor.recentCalls.length),
1553
1199
  });
1554
1200
  void (async () => {
1555
1201
  try {
1556
- const prompt = buildSemanticLoopJudgePrompt(this.hookRecentCalls, this.originalRequest);
1202
+ const prompt = buildSemanticLoopJudgePrompt(monitor.recentCalls, this.originalRequest);
1557
1203
  const raw = await (this.opts.semanticLoopJudge?.(prompt) ??
1558
1204
  this.callSemanticLoopJudge(prompt));
1559
1205
  const verdict = parseSemanticLoopVerdict(raw);
1560
- if (this.semanticLoop === runState && verdict?.loop)
1561
- runState.verdict = verdict;
1206
+ if (verdict?.loop)
1207
+ monitor.publishSemanticVerdict(runState, verdict);
1562
1208
  }
1563
1209
  catch (error) {
1564
1210
  // Fail open: judge errors never stop a run. Budget and cooldown are
@@ -1568,11 +1214,7 @@ export class AgentSession {
1568
1214
  });
1569
1215
  }
1570
1216
  finally {
1571
- if (this.semanticLoop === runState) {
1572
- runState.pending = false;
1573
- runState.checksUsed += 1;
1574
- runState.lastCheckTurn = this.hookStats.turns;
1575
- }
1217
+ monitor.settleSemanticCheck(runState);
1576
1218
  }
1577
1219
  })();
1578
1220
  }
@@ -1668,23 +1310,7 @@ export class AgentSession {
1668
1310
  * with — so reuse the same stuck signals that drive the loop-breaker.
1669
1311
  */
1670
1312
  shouldExtendTurnBudget(ctx) {
1671
- const refusals = [];
1672
- const stuck = evaluateLoopBreak({
1673
- consecutiveFailures: this.hookConsecutiveFailures,
1674
- repeatedNoProgressCalls: this.hookRepeatedNoProgressCalls,
1675
- textRepetitionDetected: detectTextRepetition(this.hookText),
1676
- ...(this.hookCyclicPattern ? { cyclicPattern: this.hookCyclicPattern } : {}),
1677
- });
1678
- if (stuck.shouldBreak)
1679
- refusals.push(...stuck.reasons);
1680
- // Stage 2 means the loop-breaker already detected spinning twice and told
1681
- // the agent to stop and report. Do not overrule that with more turns.
1682
- if (this.loopBreakInjected >= 2)
1683
- refusals.push("loop-breaker already escalated");
1684
- const { toolCalls, toolFailures } = this.hookStats;
1685
- if (toolCalls > 0 && toolFailures / toolCalls > TURN_EXTENSION_MAX_FAILURE_RATIO) {
1686
- refusals.push(`${toolFailures}/${toolCalls} tool calls failed`);
1687
- }
1313
+ const refusals = this.loopMonitor.turnExtensionRefusals();
1688
1314
  const granted = refusals.length === 0;
1689
1315
  log("INFO", "turn-budget", granted ? "Extending turn budget" : "Refusing turn-budget extension", {
1690
1316
  turn: String(ctx.turn),
@@ -1737,11 +1363,7 @@ export class AgentSession {
1737
1363
  async getHookFollowUpMessages() {
1738
1364
  // Exit notifications and task_output refer to the same host process record.
1739
1365
  // Do not force an extra polling tool/turn merely to acknowledge a known exit.
1740
- let backgroundChanged = false;
1741
- for (const id of this.backgroundVerification.keys()) {
1742
- backgroundChanged =
1743
- (await this.recordFinishedBackgroundVerification(id)) || backgroundChanged;
1744
- }
1366
+ const backgroundChanged = await this.verificationTracker.settleFinishedBackground();
1745
1367
  if (backgroundChanged)
1746
1368
  await this.persistVerificationState();
1747
1369
  // Edits return immediately; only the completion boundary waits for remaining
@@ -1817,6 +1439,22 @@ export class AgentSession {
1817
1439
  // verification demand above instead of manufacturing a separate hook.
1818
1440
  if (diagnosticMessages.length > 0)
1819
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
+ }
1820
1458
  if (this.opts.completionReview) {
1821
1459
  const followUp = await this.opts.completionReview.followUp((request, signal) => this.callCompletionReviewer(request, signal), this.opts.signal);
1822
1460
  await this.persistCompletionReviewState();
@@ -2043,6 +1681,11 @@ export class AgentSession {
2043
1681
  maxTurnToolResultChars: resolveSessionTurnToolResultCharLimit(this.model, this.provider, accountId),
2044
1682
  // Warn when web/MCP output contains instruction-like text (see injection-detect.ts).
2045
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) }),
2046
1689
  // Self-correction hooks (same as the TUI): loop-break + re-grounding are
2047
1690
  // polled mid-loop; the ideal review is polled when the agent would stop.
2048
1691
  getSteeringMessages: () => this.getHookSteeringMessages(),
@@ -2376,11 +2019,12 @@ export class AgentSession {
2376
2019
  // there avoids tearing down a live stdio child and
2377
2020
  // gambling on a `npx` re-spawn that could fail and drop the tools.
2378
2021
  const glmInvolved = this.provider === "glm" || prevProvider === "glm";
2379
- if (this.mcpManager && glmInvolved) {
2022
+ const mcpManager = this.mcp.manager;
2023
+ if (mcpManager && glmInvolved) {
2380
2024
  // Remove old MCP tools
2381
2025
  this.tools = this.tools.filter((t) => !t.name.startsWith("mcp__"));
2382
2026
  // Disconnect old MCP servers
2383
- await this.mcpManager.dispose();
2027
+ await mcpManager.dispose();
2384
2028
  // Connect new MCP servers for the new provider
2385
2029
  try {
2386
2030
  let apiKey;
@@ -2397,13 +2041,11 @@ export class AgentSession {
2397
2041
  const servers = await getAllMcpServers(this.provider, apiKey, this.cwd, {
2398
2042
  allowProjectScope: this.settingsManager.isProjectTrusted(this.cwd),
2399
2043
  });
2400
- const mcpTools = await this.mcpManager.connectAll(servers);
2044
+ const mcpTools = await mcpManager.connectAll(servers);
2401
2045
  // Drop stale MCP tools from both the live set and deferred catalog before
2402
2046
  // re-adding. Some tools may already have been promoted out of the catalog.
2403
2047
  this.tools = this.tools.filter((t) => !t.name.startsWith("mcp__"));
2404
- this.mcpCatalog?.removeWhere((name) => name.startsWith("mcp__"));
2405
- this.liveMcpTools.clear();
2406
- this.cachedMcpToolServers.clear();
2048
+ this.mcp.clearMcpTools();
2407
2049
  this.addMcpTools(mcpTools);
2408
2050
  }
2409
2051
  catch (err) {
@@ -2446,10 +2088,7 @@ export class AgentSession {
2446
2088
  this.checkpointGeneration = loaded.header.generation ?? 0;
2447
2089
  this.currentLeafId = loaded.header.leafId;
2448
2090
  this.setSessionPath(loaded.path);
2449
- this.nolanTurns = this.sessionManager.getNolanTurns(loaded.entries, loaded.header.leafId);
2450
- this.autopilotMarkers = this.sessionManager.getAutopilotMarkers(loaded.entries, loaded.header.leafId);
2451
- this.appMarkers = this.sessionManager.getAppMarkers(loaded.entries, loaded.header.leafId);
2452
- this.turnMetrics = this.sessionManager.getTurnMetrics(loaded.entries);
2091
+ this.markers.restore(loaded.entries, loaded.header.leafId);
2453
2092
  this.lastPersistedIndex = this.messages.length;
2454
2093
  this.providerContext = null;
2455
2094
  await this.subAgentManager?.rebindParentSession(this.sessionId);
@@ -2489,10 +2128,7 @@ export class AgentSession {
2489
2128
  await this.persistMessage(message);
2490
2129
  }
2491
2130
  this.lastPersistedIndex = this.messages.length;
2492
- await this.rePersistTurnMetrics();
2493
- await this.rePersistNolanTurns();
2494
- await this.rePersistAutopilotMarkers();
2495
- await this.rePersistAppMarkers();
2131
+ await this.markers.rePersistAll();
2496
2132
  await this.persistVerificationState();
2497
2133
  await this.persistAppMarker("compaction", {
2498
2134
  originalCount: result.originalCount,
@@ -2851,12 +2487,17 @@ export class AgentSession {
2851
2487
  });
2852
2488
  }
2853
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();
2854
2495
  this.cacheDiagnostics.reset();
2855
2496
  // Approved-plan execution is a clean checkpoint of the same conversation;
2856
2497
  // explicit new sessions reset the conversation identity.
2857
2498
  if (!preserveConversation) {
2858
2499
  this.verificationGate.reset();
2859
- this.backgroundVerification.clear();
2500
+ this.verificationTracker.clearBackground();
2860
2501
  this.conversationId = "";
2861
2502
  this.checkpointGeneration = 0;
2862
2503
  this.sessionPreview = "";
@@ -2868,10 +2509,7 @@ export class AgentSession {
2868
2509
  // turns / autopilot verdicts / app markers linger in memory, show up in the
2869
2510
  // new session's /history, and get re-persisted into the new file by the
2870
2511
  // next compaction — the cross-session duplicate-marker propagation bug.
2871
- this.nolanTurns = [];
2872
- this.autopilotMarkers = [];
2873
- this.appMarkers = [];
2874
- this.turnMetrics = [];
2512
+ this.markers.clear();
2875
2513
  const basePrompt = await this.buildBasePrompt(false, undefined);
2876
2514
  this.baseSystemPrompt = basePrompt;
2877
2515
  this.messages = [{ role: "system", content: this.withSystemPromptTail(basePrompt) }];
@@ -2993,9 +2631,7 @@ export class AgentSession {
2993
2631
  else {
2994
2632
  used = calculateActiveContextTokens(this.messages);
2995
2633
  }
2996
- const costUsd = this.turnMetrics.length > 0 && this.turnMetrics.every((m) => m.cost.status === "known")
2997
- ? this.turnMetrics.reduce((sum, m) => sum + (m.cost.status === "known" ? m.cost.usd : 0), 0)
2998
- : undefined;
2634
+ const costUsd = this.markers.knownCostUsd();
2999
2635
  return costUsd === undefined ? { used, size } : { used, size, costUsd };
3000
2636
  }
3001
2637
  /**
@@ -3119,6 +2755,11 @@ export class AgentSession {
3119
2755
  */
3120
2756
  async setPlanMode(active) {
3121
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
+ }
3122
2763
  // Entering plan mode discards any prior approved-plan contract (a new plan
3123
2764
  // is about to be drafted); exiting keeps it (set explicitly via accept).
3124
2765
  if (active)
@@ -3322,7 +2963,10 @@ export class AgentSession {
3322
2963
  orchestrationPolicyTail() {
3323
2964
  if (this.opts.orchestrationPrompt === false)
3324
2965
  return "";
3325
- 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();
3326
2970
  }
3327
2971
  /**
3328
2972
  * Re-render the uncached tail of messages[0] in place. The cached prefix is
@@ -3344,12 +2988,7 @@ export class AgentSession {
3344
2988
  return this.messages;
3345
2989
  }
3346
2990
  getTurnMetrics() {
3347
- return this.turnMetrics.map((metric) => ({
3348
- ...metric,
3349
- usage: { ...metric.usage },
3350
- timing: { ...metric.timing },
3351
- cost: { ...metric.cost },
3352
- }));
2991
+ return this.markers.getTurnMetrics();
3353
2992
  }
3354
2993
  async persistTurnMetric(event) {
3355
2994
  if (event.stopReason === "error")
@@ -3376,31 +3015,23 @@ export class AgentSession {
3376
3015
  reason: "No authoritative effective-dated provider pricing is available",
3377
3016
  },
3378
3017
  };
3379
- this.turnMetrics.push(payload);
3018
+ this.markers.addTurnMetric(payload);
3380
3019
  // Internal diagnostics piggyback on the authoritative per-turn metric —
3381
3020
  // one source of truth, and the record flushes to disk every turn.
3382
3021
  this.diagnosticsRecorder?.recordTurnMetric(payload);
3383
- if (this.sessionPath)
3384
- await this.sessionManager.appendTurnMetric(this.sessionPath, payload);
3385
- }
3386
- async rePersistTurnMetrics() {
3387
- if (!this.sessionPath)
3388
- return;
3389
- for (const metric of this.turnMetrics) {
3390
- await this.sessionManager.appendTurnMetric(this.sessionPath, metric);
3391
- }
3022
+ await this.markers.persistTurnMetric(payload);
3392
3023
  }
3393
3024
  /** Nolan Grout (mentor) turns recorded against this session, in record order. Used
3394
3025
  * by the host to interleave Nolan's advisory exchanges back into the transcript
3395
3026
  * on resume. Never part of the LLM message history. */
3396
3027
  getNolanTurns() {
3397
- return this.nolanTurns;
3028
+ return this.markers.getNolanTurns();
3398
3029
  }
3399
3030
  /** Autopilot verdict markers recorded against this session, in record order.
3400
3031
  * Used by the host to interleave the auto-review loop's markers back into
3401
3032
  * the transcript on resume, mirroring `getNolanTurns`. */
3402
3033
  getAutopilotMarkers() {
3403
- return this.autopilotMarkers;
3034
+ return this.markers.getAutopilotMarkers();
3404
3035
  }
3405
3036
  /** Non-system messages that are actually on disk. Transcript markers anchor
3406
3037
  * against this (not the in-memory list, which can run ahead after a failed
@@ -3415,15 +3046,7 @@ export class AgentSession {
3415
3046
  * file, so the new file carries positions that match its own transcript.
3416
3047
  */
3417
3048
  remapMarkerAnchors(remap) {
3418
- if (!remap)
3419
- return;
3420
- const move = (payload) => ({
3421
- ...payload,
3422
- afterMessageCount: remapAnchorForCompaction(payload.afterMessageCount, remap),
3423
- });
3424
- this.nolanTurns = this.nolanTurns.map(move);
3425
- this.autopilotMarkers = this.autopilotMarkers.map(move);
3426
- this.appMarkers = this.appMarkers.map(move);
3049
+ this.markers.remapAnchors(remap);
3427
3050
  }
3428
3051
  /**
3429
3052
  * Record one Nolan Grout (mentor agent) turn against this build session: the
@@ -3436,39 +3059,7 @@ export class AgentSession {
3436
3059
  * appendEntry's own handling.
3437
3060
  */
3438
3061
  async persistNolanTurn(question, reply) {
3439
- const afterMessageCount = this.persistedTranscriptCount();
3440
- const payload = { version: 1, question, reply, afterMessageCount };
3441
- this.nolanTurns.push(payload);
3442
- if (!this.sessionPath)
3443
- return;
3444
- const entry = {
3445
- type: "custom",
3446
- kind: NOLAN_TURN_CUSTOM_KIND,
3447
- id: crypto.randomUUID(),
3448
- parentId: null,
3449
- timestamp: new Date().toISOString(),
3450
- data: payload,
3451
- };
3452
- await this.sessionManager.appendEntry(this.sessionPath, entry);
3453
- }
3454
- /** Re-append the in-memory Nolan turns to the current session file. Called after
3455
- * a continuation/compaction file is created so Nolan's advisory history isn't
3456
- * lost when the session is rewritten (those rewrites only re-persist
3457
- * messages). Each turn keeps its original `afterMessageCount` anchor. */
3458
- async rePersistNolanTurns() {
3459
- if (!this.sessionPath)
3460
- return;
3461
- for (const payload of this.nolanTurns) {
3462
- const entry = {
3463
- type: "custom",
3464
- kind: NOLAN_TURN_CUSTOM_KIND,
3465
- id: crypto.randomUUID(),
3466
- parentId: null,
3467
- timestamp: new Date().toISOString(),
3468
- data: stripRecordedPosition(payload),
3469
- };
3470
- await this.sessionManager.appendEntry(this.sessionPath, entry);
3471
- }
3062
+ await this.markers.recordNolanTurn(question, reply, this.persistedTranscriptCount());
3472
3063
  }
3473
3064
  /**
3474
3065
  * Record one autopilot verdict marker (prompted / done / human / capped)
@@ -3478,23 +3069,6 @@ export class AgentSession {
3478
3069
  * instead of dropping the marker or falling back to a raw verdict string.
3479
3070
  * No-op persistence for transient sessions (kept in memory only).
3480
3071
  */
3481
- async recordFinishedBackgroundVerification(id) {
3482
- const started = this.backgroundVerification.get(id);
3483
- const proc = this.processManager?.list().find((entry) => entry.id === id);
3484
- if (!started || !proc || proc.exitCode === null)
3485
- return false;
3486
- if (started.sourceSnapshot !== undefined) {
3487
- await this.finishSnapshotVerification(started, proc.exitCode === 0);
3488
- }
3489
- else if (proc.exitCode === 0) {
3490
- this.verificationGate.recordVerification(started.revision, started.command);
3491
- }
3492
- else {
3493
- this.verificationGate.recordFailedVerification(started.command, started.revision);
3494
- }
3495
- this.backgroundVerification.delete(id);
3496
- return true;
3497
- }
3498
3072
  getVerificationEvidence() {
3499
3073
  return this.verificationGate.evidence();
3500
3074
  }
@@ -3506,39 +3080,8 @@ export class AgentSession {
3506
3080
  evidence: this.verificationGate.evidence("run"),
3507
3081
  };
3508
3082
  }
3509
- async finishSnapshotVerification(check, passed) {
3510
- const after = check.sourceSnapshot
3511
- ? await captureVerificationSnapshot(this.opts.cwd, [...this.hookFileEditCounts.keys()])
3512
- : null;
3513
- if (after === null || after !== check.sourceSnapshot) {
3514
- this.verificationGate.requireFreshVerification(true, check.command);
3515
- this.verificationGate.recordRejectedCheck(check.command, after === null
3516
- ? "Workspace inputs could not be compared; run a read-only check after the command"
3517
- : "Command changed workspace inputs; run checks against the changed source");
3518
- if (!passed)
3519
- this.verificationGate.recordFailedVerification(check.command);
3520
- }
3521
- else if (passed) {
3522
- const classification = classifyVerificationCommand(check.command);
3523
- if (classification.snapshotPreserveOnly) {
3524
- this.verificationGate.recordRejectedCheck(check.command, classification.reason);
3525
- }
3526
- else {
3527
- this.verificationGate.recordVerification(check.revision, check.command);
3528
- }
3529
- }
3530
- else {
3531
- this.verificationGate.recordFailedVerification(check.command, check.revision);
3532
- }
3533
- }
3534
3083
  getVerificationProblem() {
3535
- if ([...this.hookToolCalls.values()].some((call) => call.sourceSnapshot !== undefined)) {
3536
- return "Unverified: a build is still running or its workspace comparison is pending.";
3537
- }
3538
- return (this.verificationGate.verificationProblem() ??
3539
- (this.backgroundVerification.size > 0
3540
- ? "Unverified: a background check is still running or its result has not been confirmed."
3541
- : null));
3084
+ return this.verificationTracker.verificationProblem();
3542
3085
  }
3543
3086
  async persistCompletionReviewState() {
3544
3087
  if (!this.sessionPath || !this.opts.completionReview)
@@ -3571,50 +3114,13 @@ export class AgentSession {
3571
3114
  await this.sessionManager.appendEntry(this.sessionPath, entry);
3572
3115
  }
3573
3116
  async persistAutopilotMarker(phase, extra) {
3574
- const afterMessageCount = this.persistedTranscriptCount();
3575
- const payload = {
3576
- version: 1,
3577
- phase,
3578
- afterMessageCount,
3579
- ...(extra?.reason !== undefined ? { reason: extra.reason } : {}),
3580
- ...(extra?.body !== undefined ? { body: extra.body } : {}),
3581
- };
3582
- this.autopilotMarkers.push(payload);
3583
- if (!this.sessionPath)
3584
- return;
3585
- const entry = {
3586
- type: "custom",
3587
- kind: AUTOPILOT_MARKER_CUSTOM_KIND,
3588
- id: crypto.randomUUID(),
3589
- parentId: null,
3590
- timestamp: new Date().toISOString(),
3591
- data: payload,
3592
- };
3593
- await this.sessionManager.appendEntry(this.sessionPath, entry);
3594
- }
3595
- /** Re-append the in-memory autopilot markers to the current session file.
3596
- * Mirrors `rePersistNolanTurns` — called after a continuation/compaction file
3597
- * is created so the auto-review history survives the rewrite. */
3598
- async rePersistAutopilotMarkers() {
3599
- if (!this.sessionPath)
3600
- return;
3601
- for (const payload of this.autopilotMarkers) {
3602
- const entry = {
3603
- type: "custom",
3604
- kind: AUTOPILOT_MARKER_CUSTOM_KIND,
3605
- id: crypto.randomUUID(),
3606
- parentId: null,
3607
- timestamp: new Date().toISOString(),
3608
- data: stripRecordedPosition(payload),
3609
- };
3610
- await this.sessionManager.appendEntry(this.sessionPath, entry);
3611
- }
3117
+ await this.markers.recordAutopilotMarker(phase, extra, this.persistedTranscriptCount());
3612
3118
  }
3613
3119
  /** App transcript markers recorded against this session, in record order.
3614
3120
  * Used by the host to interleave display-only rows (plan banner, task
3615
3121
  * header, errors, user-bubble hints) back into the transcript on resume. */
3616
3122
  getAppMarkers() {
3617
- return this.appMarkers;
3123
+ return this.markers.getAppMarkers();
3618
3124
  }
3619
3125
  /**
3620
3126
  * Record one app transcript marker (display-only row) against this session.
@@ -3626,20 +3132,7 @@ export class AgentSession {
3626
3132
  * No-op persistence for transient sessions.
3627
3133
  */
3628
3134
  async persistAppMarker(kind, data, anchorOffset = 0) {
3629
- const afterMessageCount = this.persistedTranscriptCount() + anchorOffset;
3630
- const payload = { version: 1, kind, afterMessageCount, data };
3631
- this.appMarkers.push(payload);
3632
- if (!this.sessionPath)
3633
- return;
3634
- const entry = {
3635
- type: "custom",
3636
- kind: APP_MARKER_CUSTOM_KIND,
3637
- id: crypto.randomUUID(),
3638
- parentId: null,
3639
- timestamp: new Date().toISOString(),
3640
- data: payload,
3641
- };
3642
- await this.sessionManager.appendEntry(this.sessionPath, entry);
3135
+ await this.markers.recordAppMarker(kind, data, this.persistedTranscriptCount() + anchorOffset);
3643
3136
  }
3644
3137
  /**
3645
3138
  * Open the run journal for one `RunLifecycle` generation.
@@ -3668,24 +3161,6 @@ export class AgentSession {
3668
3161
  outcome,
3669
3162
  });
3670
3163
  }
3671
- /** Re-append the in-memory app markers to the current session file. Mirrors
3672
- * `rePersistNolanTurns` — called after a continuation/compaction file is
3673
- * created so display-only rows survive the rewrite. */
3674
- async rePersistAppMarkers() {
3675
- if (!this.sessionPath)
3676
- return;
3677
- for (const payload of this.appMarkers) {
3678
- const entry = {
3679
- type: "custom",
3680
- kind: APP_MARKER_CUSTOM_KIND,
3681
- id: crypto.randomUUID(),
3682
- parentId: null,
3683
- timestamp: new Date().toISOString(),
3684
- data: stripRecordedPosition(payload),
3685
- };
3686
- await this.sessionManager.appendEntry(this.sessionPath, entry);
3687
- }
3688
- }
3689
3164
  /**
3690
3165
  * Rewrite a draft prompt into a tighter, terminology-correct version using
3691
3166
  * the ACTIVE provider/model. A stateless one-off LLM call (no agent loop, no
@@ -3927,7 +3402,7 @@ export class AgentSession {
3927
3402
  this.stopBackgroundProcesses();
3928
3403
  this.lspManager?.shutdownAll();
3929
3404
  this.debugManager?.shutdown();
3930
- await Promise.all([this.subAgentManager?.shutdownAll(), this.mcpManager?.dispose()]);
3405
+ await Promise.all([this.subAgentManager?.shutdownAll(), this.mcp.manager?.dispose()]);
3931
3406
  await this.extensionLoader.deactivateAll();
3932
3407
  this.setSessionPath("");
3933
3408
  this.eventBus.removeAllListeners();
@@ -3972,7 +3447,7 @@ export class AgentSession {
3972
3447
  .reverse()
3973
3448
  .find((entry) => entry.type === "custom" && entry.kind === COMPLETION_REVIEW_STATE_KIND);
3974
3449
  this.opts.completionReview?.restore(savedCompletionReview?.type === "custom" ? savedCompletionReview.data : null);
3975
- this.backgroundVerification.clear();
3450
+ this.verificationTracker.clearBackground();
3976
3451
  const savedVerification = [...loaded.entries]
3977
3452
  .reverse()
3978
3453
  .find((entry) => entry.type === "custom" && entry.kind === VERIFICATION_STATE_KIND);
@@ -4002,12 +3477,8 @@ export class AgentSession {
4002
3477
  // Restore Nolan's advisory turns (custom entries, not on the message branch) so
4003
3478
  // they reappear in the transcript and survive into the continuation file.
4004
3479
  // The leaf lets marker replay retain its independent file-order position.
4005
- this.nolanTurns = this.sessionManager.getNolanTurns(loaded.entries, loaded.header.leafId);
4006
- // Restore autopilot verdict markers the same way (not on the message DAG).
4007
- this.autopilotMarkers = this.sessionManager.getAutopilotMarkers(loaded.entries, loaded.header.leafId);
4008
- // Restore app transcript markers (plan banner / task header / errors / hints).
4009
- this.appMarkers = this.sessionManager.getAppMarkers(loaded.entries, loaded.header.leafId);
4010
- 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);
4011
3482
  // A run that opened the journal and never closed it died mid-flight. Read
4012
3483
  // it here, before anything rewrites the file, and report it once the
4013
3484
  // transcript is in place.
@@ -4207,13 +3678,4 @@ export class AgentSession {
4207
3678
  }
4208
3679
  }
4209
3680
  }
4210
- /** Expand a leading `~` so `/import ~/.codex/...` works from any shell. */
4211
- function resolveHomePath(filePath) {
4212
- if (filePath === "~")
4213
- return os.homedir();
4214
- if (filePath.startsWith("~/") || filePath.startsWith("~\\")) {
4215
- return path.join(os.homedir(), filePath.slice(2));
4216
- }
4217
- return filePath;
4218
- }
4219
3681
  //# sourceMappingURL=agent-session.js.map