@kenkaiiii/ggcoder 5.74.2 → 5.75.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (218) hide show
  1. package/README.md +1 -1
  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 +65 -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 +290 -0
  21. package/dist/app-sidecar/auth-routes.js.map +1 -0
  22. package/dist/app-sidecar/autopilot-runner.d.ts +61 -0
  23. package/dist/app-sidecar/autopilot-runner.js +367 -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/ken-context.d.ts +20 -0
  50. package/dist/app-sidecar/ken-context.js +55 -0
  51. package/dist/app-sidecar/ken-context.js.map +1 -0
  52. package/dist/app-sidecar/mcp-routes.d.ts +3 -0
  53. package/dist/app-sidecar/mcp-routes.js +185 -0
  54. package/dist/app-sidecar/mcp-routes.js.map +1 -0
  55. package/dist/app-sidecar/mcp-rows.d.ts +31 -0
  56. package/dist/app-sidecar/mcp-rows.js +77 -0
  57. package/dist/app-sidecar/mcp-rows.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 +354 -0
  63. package/dist/app-sidecar/prompt-routes.js.map +1 -0
  64. package/dist/app-sidecar/route-context.d.ts +145 -0
  65. package/dist/app-sidecar/route-context.js +2 -0
  66. package/dist/app-sidecar/route-context.js.map +1 -0
  67. package/dist/app-sidecar/run-control-routes.d.ts +3 -0
  68. package/dist/app-sidecar/run-control-routes.js +430 -0
  69. package/dist/app-sidecar/run-control-routes.js.map +1 -0
  70. package/dist/app-sidecar/session-routes.d.ts +3 -0
  71. package/dist/app-sidecar/session-routes.js +115 -0
  72. package/dist/app-sidecar/session-routes.js.map +1 -0
  73. package/dist/app-sidecar/session-types.d.ts +39 -0
  74. package/dist/app-sidecar/session-types.js +5 -0
  75. package/dist/app-sidecar/session-types.js.map +1 -0
  76. package/dist/app-sidecar/task-routes.d.ts +3 -0
  77. package/dist/app-sidecar/task-routes.js +149 -0
  78. package/dist/app-sidecar/task-routes.js.map +1 -0
  79. package/dist/app-sidecar/usage.d.ts +19 -0
  80. package/dist/app-sidecar/usage.js +153 -0
  81. package/dist/app-sidecar/usage.js.map +1 -0
  82. package/dist/app-sidecar/workspace-routes.d.ts +3 -0
  83. package/dist/app-sidecar/workspace-routes.js +244 -0
  84. package/dist/app-sidecar/workspace-routes.js.map +1 -0
  85. package/dist/app-sidecar.js +482 -3695
  86. package/dist/app-sidecar.js.map +1 -1
  87. package/dist/cli.js +2 -2
  88. package/dist/cli.js.map +1 -1
  89. package/dist/config.js +1 -1
  90. package/dist/core/acceptance-checks.js +3 -9
  91. package/dist/core/acceptance-checks.js.map +1 -1
  92. package/dist/core/agent-session/helpers.d.ts +26 -0
  93. package/dist/core/agent-session/helpers.js +47 -0
  94. package/dist/core/agent-session/helpers.js.map +1 -0
  95. package/dist/core/agent-session/mcp-tools.d.ts +68 -0
  96. package/dist/core/agent-session/mcp-tools.js +195 -0
  97. package/dist/core/agent-session/mcp-tools.js.map +1 -0
  98. package/dist/core/agent-session/run-loop-monitor.d.ts +63 -0
  99. package/dist/core/agent-session/run-loop-monitor.js +164 -0
  100. package/dist/core/agent-session/run-loop-monitor.js.map +1 -0
  101. package/dist/core/agent-session/transcript-markers.d.ts +57 -0
  102. package/dist/core/agent-session/transcript-markers.js +166 -0
  103. package/dist/core/agent-session/transcript-markers.js.map +1 -0
  104. package/dist/core/agent-session/verification-tracker.d.ts +46 -0
  105. package/dist/core/agent-session/verification-tracker.js +246 -0
  106. package/dist/core/agent-session/verification-tracker.js.map +1 -0
  107. package/dist/core/agent-session.d.ts +27 -87
  108. package/dist/core/agent-session.js +166 -709
  109. package/dist/core/agent-session.js.map +1 -1
  110. package/dist/core/auth-providers.js +3 -3
  111. package/dist/core/auth-providers.js.map +1 -1
  112. package/dist/core/autopilot-cycle.d.ts +6 -2
  113. package/dist/core/autopilot-cycle.js +1 -1
  114. package/dist/core/autopilot-cycle.js.map +1 -1
  115. package/dist/core/bundled-agents.js +6 -6
  116. package/dist/core/bundled-agents.js.map +1 -1
  117. package/dist/core/checklist-git.d.ts +19 -0
  118. package/dist/core/checklist-git.js +54 -0
  119. package/dist/core/checklist-git.js.map +1 -0
  120. package/dist/core/checklist-items.d.ts +10 -1
  121. package/dist/core/checklist-items.js +22 -0
  122. package/dist/core/checklist-items.js.map +1 -1
  123. package/dist/core/checklist-prompt.d.ts +5 -1
  124. package/dist/core/checklist-prompt.js +16 -5
  125. package/dist/core/checklist-prompt.js.map +1 -1
  126. package/dist/core/checklist-snapshot.d.ts +5 -1
  127. package/dist/core/checklist-snapshot.js +19 -2
  128. package/dist/core/checklist-snapshot.js.map +1 -1
  129. package/dist/core/context-limits.js +1 -1
  130. package/dist/core/context-limits.js.map +1 -1
  131. package/dist/core/plan-handoff.d.ts +30 -0
  132. package/dist/core/plan-handoff.js +10 -0
  133. package/dist/core/plan-handoff.js.map +1 -0
  134. package/dist/core/plan-submission-gate.d.ts +28 -0
  135. package/dist/core/plan-submission-gate.js +27 -0
  136. package/dist/core/plan-submission-gate.js.map +1 -0
  137. package/dist/core/skills.js +6 -9
  138. package/dist/core/skills.js.map +1 -1
  139. package/dist/core/style-packs/index.js +2 -11
  140. package/dist/core/style-packs/index.js.map +1 -1
  141. package/dist/core/style-packs/packs.d.ts +3 -12
  142. package/dist/core/style-packs/packs.js +132 -173
  143. package/dist/core/style-packs/packs.js.map +1 -1
  144. package/dist/system-prompt.js +55 -78
  145. package/dist/system-prompt.js.map +1 -1
  146. package/dist/tools/ask-user.js +13 -74
  147. package/dist/tools/ask-user.js.map +1 -1
  148. package/dist/tools/bash.js +13 -78
  149. package/dist/tools/bash.js.map +1 -1
  150. package/dist/tools/checklist.d.ts +2 -10
  151. package/dist/tools/checklist.js +7 -39
  152. package/dist/tools/checklist.js.map +1 -1
  153. package/dist/tools/code-nav.js +7 -29
  154. package/dist/tools/code-nav.js.map +1 -1
  155. package/dist/tools/edit.d.ts +3 -9
  156. package/dist/tools/edit.js +66 -65
  157. package/dist/tools/edit.js.map +1 -1
  158. package/dist/tools/enter-plan.js +2 -7
  159. package/dist/tools/enter-plan.js.map +1 -1
  160. package/dist/tools/exit-plan.js +2 -3
  161. package/dist/tools/exit-plan.js.map +1 -1
  162. package/dist/tools/find.js +3 -4
  163. package/dist/tools/find.js.map +1 -1
  164. package/dist/tools/grep.js +6 -18
  165. package/dist/tools/grep.js.map +1 -1
  166. package/dist/tools/ls.js +3 -3
  167. package/dist/tools/ls.js.map +1 -1
  168. package/dist/tools/prompt-hints.js +34 -47
  169. package/dist/tools/prompt-hints.js.map +1 -1
  170. package/dist/tools/read.js +8 -20
  171. package/dist/tools/read.js.map +1 -1
  172. package/dist/tools/search-code.js +4 -12
  173. package/dist/tools/search-code.js.map +1 -1
  174. package/dist/tools/skill.js +13 -7
  175. package/dist/tools/skill.js.map +1 -1
  176. package/dist/tools/subagent-control.js +7 -22
  177. package/dist/tools/subagent-control.js.map +1 -1
  178. package/dist/tools/subagent.js +4 -7
  179. package/dist/tools/subagent.js.map +1 -1
  180. package/dist/tools/task-output.js +4 -15
  181. package/dist/tools/task-output.js.map +1 -1
  182. package/dist/tools/task-send.js +5 -17
  183. package/dist/tools/task-send.js.map +1 -1
  184. package/dist/tools/task-stop.js +2 -2
  185. package/dist/tools/task-stop.js.map +1 -1
  186. package/dist/tools/tool-search.js +2 -7
  187. package/dist/tools/tool-search.js.map +1 -1
  188. package/dist/tools/tool-tiers.d.ts +17 -4
  189. package/dist/tools/tool-tiers.js +38 -15
  190. package/dist/tools/tool-tiers.js.map +1 -1
  191. package/dist/tools/web-fetch.js +7 -33
  192. package/dist/tools/web-fetch.js.map +1 -1
  193. package/dist/tools/write.js +3 -4
  194. package/dist/tools/write.js.map +1 -1
  195. package/dist/ui/components/Footer.js +1 -2
  196. package/dist/ui/components/Footer.js.map +1 -1
  197. package/package.json +7 -4
  198. package/dist/core/api-benchmark.d.ts +0 -63
  199. package/dist/core/api-benchmark.js +0 -381
  200. package/dist/core/api-benchmark.js.map +0 -1
  201. package/dist/core/bash-spawn-benchmark.d.ts +0 -1
  202. package/dist/core/bash-spawn-benchmark.js +0 -139
  203. package/dist/core/bash-spawn-benchmark.js.map +0 -1
  204. package/dist/core/cache-warm-benchmark.d.ts +0 -21
  205. package/dist/core/cache-warm-benchmark.js +0 -170
  206. package/dist/core/cache-warm-benchmark.js.map +0 -1
  207. package/dist/core/fast-apply-benchmark.d.ts +0 -25
  208. package/dist/core/fast-apply-benchmark.js +0 -284
  209. package/dist/core/fast-apply-benchmark.js.map +0 -1
  210. package/dist/core/hashline-edit-benchmark.d.ts +0 -88
  211. package/dist/core/hashline-edit-benchmark.js +0 -518
  212. package/dist/core/hashline-edit-benchmark.js.map +0 -1
  213. package/dist/core/semantic-search-benchmark.d.ts +0 -36
  214. package/dist/core/semantic-search-benchmark.js +0 -211
  215. package/dist/core/semantic-search-benchmark.js.map +0 -1
  216. package/dist/core/speed-benchmark.d.ts +0 -132
  217. package/dist/core/speed-benchmark.js +0 -410
  218. package/dist/core/speed-benchmark.js.map +0 -1
@@ -13,30 +13,26 @@ import { dualAuthProvider } from "@kenkaiiii/gg-core";
13
13
  import { getClaudeCliUserAgent } 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, KEN_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
- import { autoBackgroundedId, REVIEW_REJECTED_BEFORE_START } from "../tools/bash.js";
28
27
  import { buildProcessCompletionFollowUp } from "./process-gate.js";
28
+ import { PLAN_SUBMISSION_NUDGE, shouldNudgePlanSubmission } from "./plan-submission-gate.js";
29
29
  import { buildSubAgentCompletionFollowUp } from "./subagent-manager.js";
30
30
  import { applyAsyncSubagentPolicy } from "./subagent-policy.js";
31
31
  import { resolveResponsesLite, resolveStrictTools, } from "./codex-request-shape.js";
32
32
  import { subAgentDescription } from "../tools/subagent.js";
33
- import { z } from "zod";
34
- import { MCPClientManager, getAllMcpServers } from "./mcp/index.js";
35
- import { clampMcpToolDescription, DeferredToolCatalog } from "./mcp/deferred-catalog.js";
33
+ import { getAllMcpServers } from "./mcp/index.js";
36
34
  import { CONTEXT_LIMITS, resolveSessionContextLimits, } from "./context-limits.js";
37
- import { McpCatalogCache } from "./mcp/catalog-cache.js";
38
35
  import { describeDropped, importForeignSession, } from "./foreign-session-import.js";
39
- import { createToolSearchTool } from "../tools/tool-search.js";
40
36
  import { createSessionStatsTool } from "../tools/session-stats.js";
41
37
  import { createDiagnoseCommand, isInternalDiagnosticsEnabled, SessionDiagnosticsRecorder, } from "./internal-diagnostics.js";
42
38
  import { log } from "./logger.js";
@@ -53,15 +49,13 @@ import { pruneStaleToolResults } from "./compaction/tool-result-pruner.js";
53
49
  import { discoverAgents } from "./agents.js";
54
50
  import { enhancePrompt } from "../utils/prompt-enhancer.js";
55
51
  import { detectLanguages, detectProjectStack } from "./language-detector.js";
56
- import { evaluateLoopBreak, buildLoopBreakMessage, CycleDetector, ToolCallProgressTracker, detectTextRepetition, } from "./loop-breaker.js";
52
+ import { buildLoopBreakMessage } from "./loop-breaker.js";
57
53
  import { buildRegroundingMessage, requestTextForRegrounding } from "./regrounding.js";
58
- import { buildSemanticLoopJudgePrompt, buildSemanticLoopMessage, MAX_SEMANTIC_LOOP_CALLS, parseSemanticLoopVerdict, shouldRunSemanticLoopCheck, SEMANTIC_LOOP_JUDGE_TIMEOUT_MS, withJudgeTimeout, } from "./semantic-loop-check.js";
54
+ import { buildSemanticLoopJudgePrompt, buildSemanticLoopMessage, parseSemanticLoopVerdict, SEMANTIC_LOOP_JUDGE_TIMEOUT_MS, withJudgeTimeout, } from "./semantic-loop-check.js";
59
55
  import { buildEnvDeltaMessage } from "./env-delta.js";
60
56
  import { wrapSteeringText, buildNotificationSteeringText, STEERING_PREFIX } from "./steering.js";
61
57
  import { AgentNotificationQueue } from "./agent-notifications.js";
62
- import { VerificationGate, isCheckOwnFile, isCodeFilePath, VERIFICATION_STATE_KIND, isVerificationCommand, } from "./verification-gate.js";
63
- import { classifyVerificationCommand } from "./verification-evidence.js";
64
- import { captureVerificationSnapshot } from "./verification-snapshot.js";
58
+ import { VerificationGate, VERIFICATION_STATE_KIND } from "./verification-gate.js";
65
59
  import { findUserSessionPrompt, getUserSessionPrompt } from "./session-preview.js";
66
60
  import { normalizeMessageImages } from "./message-images.js";
67
61
  import { loadStreamRules } from "./stream-rules.js";
@@ -69,41 +63,12 @@ import crypto from "node:crypto";
69
63
  import fs from "node:fs/promises";
70
64
  import os from "node:os";
71
65
  import path from "node:path";
72
- import { editTargetPaths } from "../tools/edit-targets.js";
73
- /**
74
- * A run whose tool calls fail more often than this is thrashing, not
75
- * progressing — refuse to extend its turn budget.
76
- */
77
- const TURN_EXTENSION_MAX_FAILURE_RATIO = 0.5;
78
- // ── Tool-result policy ─────────────────────────────────────
79
- /** Resolve the per-result cap passed to the agent loop for the active transport. */
80
- export function resolveSessionToolResultCharLimit(model, provider, accountId) {
81
- return (getToolResultCharLimit(model, { provider, accountId }) ??
82
- Math.floor(getContextWindow(model, { provider, accountId }) * 3.5 * 0.3));
83
- }
84
- /**
85
- * Aggregate budget for ALL tool results produced in one assistant turn.
86
- * Individual results are already capped, but wide parallel fan-outs (GPT-5.6's
87
- * signature behavior) were observed injecting 100k+ uncached tokens in a single
88
- * turn. ~15% of the context window in chars (1 token ≈ 3.5 chars), floored at
89
- * 100KB so small windows still fit two full-size reads, ceilinged at 240KB so
90
- * 1M-context models don't waive the budget entirely.
91
- */
92
- export function resolveSessionTurnToolResultCharLimit(model, provider, accountId) {
93
- const contextChars = getContextWindow(model, { provider, accountId }) * 3.5;
94
- return Math.max(100_000, Math.min(Math.floor(contextChars * 0.15), 240_000));
95
- }
96
- /** Marker the compactor prepends to the summary message it injects. */
97
- /**
98
- * True when an assistant message ends a turn with tool calls still awaiting
99
- * their results. Inserting a user message there would orphan the tool_use
100
- * blocks and the provider rejects the next request.
101
- */
102
- function hasUnresolvedToolCalls(message) {
103
- if (typeof message.content === "string" || !Array.isArray(message.content))
104
- return false;
105
- return message.content.some((part) => part.type === "tool_call");
106
- }
66
+ import { resolveSessionToolResultCharLimit, resolveSessionTurnToolResultCharLimit, hasUnresolvedToolCalls, resolveHomePath, } from "./agent-session/helpers.js";
67
+ import { RunLoopMonitor } from "./agent-session/run-loop-monitor.js";
68
+ import { VerificationTracker } from "./agent-session/verification-tracker.js";
69
+ import { McpToolRegistry } from "./agent-session/mcp-tools.js";
70
+ import { TranscriptMarkers } from "./agent-session/transcript-markers.js";
71
+ export { resolveSessionToolResultCharLimit, resolveSessionTurnToolResultCharLimit, } from "./agent-session/helpers.js";
107
72
  // ── Agent Session ──────────────────────────────────────────
108
73
  export class AgentSession {
109
74
  eventBus = new EventBus();
@@ -113,24 +78,10 @@ export class AgentSession {
113
78
  sessionManager;
114
79
  extensionLoader = new ExtensionLoader();
115
80
  messages = [];
116
- // Ken Kai (mentor agent) turns recorded against this build session. Advisory
117
- // only — NEVER part of `messages` (GG Coder must not see them), but persisted
118
- // alongside the session and reloaded on resume so they reappear in the
119
- // transcript. Each carries the non-system message count at record time so the
120
- // webview can interleave them chronologically.
121
- kenTurns = [];
122
- // Autopilot Ken (auto-reviewer) markers recorded against this build session:
123
- // the review verdict shown in the transcript (prompted / done / human /
124
- // capped). Same not-on-the-DAG treatment as kenTurns — advisory only,
125
- // persisted + reloaded so a resumed session shows the identical Ken bubble
126
- // the live run showed instead of dropping it or replaying a raw verdict.
127
- autopilotMarkers = [];
128
- // Generic app transcript markers (plan-mode banner, task header, error rows,
129
- // user-bubble display hints). Same not-on-the-DAG treatment as kenTurns —
130
- // display only, persisted + reloaded so a resumed session shows the same
131
- // transcript rows the live run showed.
132
- appMarkers = [];
133
- turnMetrics = [];
81
+ // Display-only transcript history (Ken turns, autopilot verdicts, app markers,
82
+ // turn metrics). NEVER part of `messages` (GG Coder must not see them), but
83
+ // persisted alongside the session and reloaded on resume.
84
+ markers = new TranscriptMarkers(() => this.sessionManager, () => this.sessionPath);
134
85
  cacheDiagnostics = new CacheDiagnostics();
135
86
  /** Internal-only (GG_INTERNAL): live per-session cost/reliability recorder.
136
87
  * Absent entirely in public builds — see core/internal-diagnostics.ts. */
@@ -149,25 +100,8 @@ export class AgentSession {
149
100
  // ── Self-correction hook state (mirrors the TUI's useAgentLoop refs) ──
150
101
  // Reset at the start of every run; observed from the event stream; read by
151
102
  // the loop-break (mid-loop) callback.
152
- hookStats = { toolCalls: 0, toolFailures: 0, turns: 0 };
153
- hookText = "";
154
- hookConsecutiveFailures = 0;
155
- hookRepeatedNoProgressCalls = 0;
156
- hookProgressTracker = new ToolCallProgressTracker();
157
- hookCycleDetector = new CycleDetector();
158
- hookCyclicPattern = null;
159
- hookFileEditCounts = new Map();
160
- hookToolCalls = new Map();
161
- backgroundVerification = new Map();
162
- /** 0 = none; 1 = first nudge sent; 2 = final stop-and-report injected. */
163
- loopBreakInjected = 0;
103
+ loopMonitor = new RunLoopMonitor();
164
104
  regroundingInjected = false;
165
- /** Recent tool-call digests for the semantic loop judge — bounded ring. */
166
- hookRecentCalls = [];
167
- /** LLM-judged loop detection state. `verdict` holds a LOOP verdict awaiting
168
- * injection at the next steering poll; judge failures fail open (no
169
- * injection) and still consume budget + cooldown. */
170
- semanticLoop = { checksUsed: 0, lastCheckTurn: 0, pending: false, verdict: null, injected: false };
171
105
  /**
172
106
  * The environment as the cached system prompt currently describes it.
173
107
  * Re-recorded on every prompt build, so a rebuild (e.g. `/add-dir`) needs no
@@ -178,9 +112,13 @@ export class AgentSession {
178
112
  runStartedAt = 0;
179
113
  /** Gate injections spent this run, capped by MAX_PROCESS_GATE_INJECTIONS. */
180
114
  processGateInjected = 0;
115
+ /** Plan-submission reminders spent this run (plan-submission-gate.ts). */
116
+ planSubmissionNudges = 0;
181
117
  /** Verification evidence: code edited this run, and what has proved it since.
182
118
  * Passive tracking only — it feeds run status and autopilot, never a turn. */
183
119
  verificationGate = new VerificationGate();
120
+ /** Host-observed evidence (edited files, in-flight and background checks) feeding the gate. */
121
+ verificationTracker;
184
122
  /** Mirror of the last `hook_armed` value, so the event fires only on an edge. */
185
123
  preFinalArmed = false;
186
124
  compactionOccurred = false;
@@ -246,9 +184,8 @@ export class AgentSession {
246
184
  this.lspManager?.clearPendingDiagnostics();
247
185
  void this.subAgentManager?.interruptAll();
248
186
  };
249
- mcpManager;
250
- /** Deferred MCP tools awaiting discovery via tool_search. */
251
- mcpCatalog;
187
+ /** MCP client, deferred tool_search catalog and cached-stub reconcile state. */
188
+ mcp = new McpToolRegistry(() => this.tools, () => this.contextLimits);
252
189
  /** Resolved prompt-injection byte budgets (contextLimits setting). */
253
190
  contextLimits = CONTEXT_LIMITS;
254
191
  /**
@@ -257,11 +194,6 @@ export class AgentSession {
257
194
  * can discover and promote them; a promoted name drops out of this list.
258
195
  */
259
196
  deferredBuiltinToolNames = [];
260
- /** Live (connected) MCP tools by name — the reconcile target for cached stubs. */
261
- liveMcpTools = new Map();
262
- /** Server name for each cached-only tool, so a stub knows what to wait on. */
263
- cachedMcpToolServers = new Map();
264
- mcpCatalogCache = new McpCatalogCache();
265
197
  provider;
266
198
  model;
267
199
  cwd;
@@ -334,6 +266,7 @@ export class AgentSession {
334
266
  opts;
335
267
  constructor(options) {
336
268
  this.opts = options;
269
+ this.verificationTracker = new VerificationTracker(this.verificationGate, options.cwd, () => this.processManager?.list() ?? []);
337
270
  this.provider = options.provider;
338
271
  this.model = options.model;
339
272
  this.cwd = options.cwd;
@@ -435,7 +368,7 @@ export class AgentSession {
435
368
  authStorage: this.authStorage,
436
369
  onFileMutated: (filePath) => {
437
370
  const relative = path.relative(this.cwd, filePath) || path.basename(filePath);
438
- this.hookFileEditCounts.set(relative, (this.hookFileEditCounts.get(relative) ?? 0) + 1);
371
+ this.verificationTracker.recordFileMutated(relative);
439
372
  },
440
373
  // Lazy — sessionId/model/provider can change after createTools() runs, so
441
374
  // sub-agent spawns read the current parent state at execution time.
@@ -482,6 +415,10 @@ export class AgentSession {
482
415
  // every request. Allow-listed sessions keep the eager path — their fixed
483
416
  // tool expectations predate the catalog, and tool_search isn't allow-listed.
484
417
  if (!this.opts.allowedTools && this.settingsManager.get("deferredBuiltinTools")) {
418
+ // Wrap before partitioning: spawn_agent itself may be deferred, and the
419
+ // catalog must hold the wrapped tool so a promoted spawn still loads
420
+ // wait_agent.
421
+ this.promoteFollowUpTools();
485
422
  const { core, deferred } = partitionToolsByTier(this.tools);
486
423
  if (deferred.length > 0) {
487
424
  // Append-only: `core` preserves the original relative order and
@@ -489,10 +426,7 @@ export class AgentSession {
489
426
  // sits inside the cached prefix stays byte-stable across turns.
490
427
  this.tools = core;
491
428
  this.deferredBuiltinToolNames = deferred.map((t) => t.name);
492
- this.mcpCatalog ??= new DeferredToolCatalog(this.contextLimits);
493
- this.mcpCatalog.add(deferred);
494
- this.ensureToolSearchTool();
495
- this.promoteWaitAgentAfterSpawn();
429
+ this.mcp.addDeferred(deferred);
496
430
  }
497
431
  }
498
432
  this.rebuildReadTool = rebuildReadTool;
@@ -510,8 +444,7 @@ export class AgentSession {
510
444
  // its listening handshake until this resolves), `backgroundMcpConnect`
511
445
  // moves the connect off the critical path so the session becomes usable
512
446
  // immediately and tools are appended whenever the servers come up.
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 (Ken) 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
@@ -1087,33 +916,15 @@ export class AgentSession {
1087
916
  resetHookState(originalRequest) {
1088
917
  this.opts.completionReview?.begin(originalRequest);
1089
918
  this.lspManager?.clearPendingDiagnostics();
1090
- this.hookStats = { toolCalls: 0, toolFailures: 0, turns: 0 };
1091
- this.hookText = "";
1092
- this.hookConsecutiveFailures = 0;
1093
- this.hookRepeatedNoProgressCalls = 0;
1094
- this.hookProgressTracker.reset();
1095
- this.hookCycleDetector.reset();
1096
- this.hookCyclicPattern = null;
1097
- this.hookFileEditCounts.clear();
1098
- this.hookToolCalls.clear();
1099
- this.loopBreakInjected = 0;
919
+ this.loopMonitor.reset();
920
+ this.verificationTracker.resetRun();
1100
921
  this.regroundingInjected = false;
1101
- this.hookRecentCalls = [];
1102
- this.semanticLoop = {
1103
- checksUsed: 0,
1104
- lastCheckTurn: 0,
1105
- pending: false,
1106
- verdict: null,
1107
- injected: false,
1108
- };
1109
922
  this.runStartedAt = Date.now();
1110
923
  this.processGateInjected = 0;
924
+ this.planSubmissionNudges = 0;
1111
925
  this.verificationGate.beginRun();
1112
926
  const processes = new Set(this.processManager?.list().map((p) => p.id) ?? []);
1113
- for (const id of this.backgroundVerification.keys()) {
1114
- if (!processes.has(id))
1115
- this.backgroundVerification.delete(id);
1116
- }
927
+ this.verificationTracker.pruneBackground(processes);
1117
928
  this.compactionOccurred = false;
1118
929
  // Post-turn compaction may have landed between runs — adopt its armed
1119
930
  // signal so the re-grounding hook fires for this run exactly as it would
@@ -1137,157 +948,20 @@ export class AgentSession {
1137
948
  }
1138
949
  switch (event.type) {
1139
950
  case "text_delta":
1140
- this.hookText += event.text;
951
+ this.loopMonitor.recordText(event.text);
1141
952
  break;
1142
953
  case "tool_call_start": {
1143
- this.hookToolCalls.set(event.toolCallId, {
1144
- name: event.name,
1145
- args: event.args ?? {},
1146
- revision: this.verificationGate.revision,
1147
- });
1148
- const startClassification = event.name === "bash" && typeof event.args?.command === "string"
1149
- ? classifyVerificationCommand(event.args.command)
1150
- : null;
1151
- if (startClassification &&
1152
- typeof event.args?.command === "string" &&
1153
- (isVerificationCommand(event.args.command) ||
1154
- startClassification.accepted ||
1155
- // Must match the tool_call_end predicate: a snapshot-eligible
1156
- // command that never captured a "before" snapshot is misread at
1157
- // the end as an uncomparable workspace and re-arms the gate.
1158
- startClassification.snapshotEligible === true)) {
1159
- // A check that can rewrite files (--fix, build scripts, emitters)
1160
- // invalidates earlier in-flight evidence AND marks the run as
1161
- // touched. A check that is merely UNRECOGNIZED (`make test`, `deno
1162
- // test`) rewrites nothing we can point to: bumping the revision for
1163
- // it poisoned the gate on green output and re-armed the hook into
1164
- // every later question turn.
1165
- this.verificationGate.recordVerificationAttempt();
1166
- const classification = startClassification;
1167
- if (classification.snapshotEligible && event.args.persist !== true) {
1168
- const call = this.hookToolCalls.get(event.toolCallId);
1169
- call.sourceSnapshot = await captureVerificationSnapshot(this.opts.cwd, [
1170
- ...this.hookFileEditCounts.keys(),
1171
- ]);
1172
- if (call.sourceSnapshot === null)
1173
- this.verificationGate.requireFreshVerification(true);
1174
- }
1175
- else if ((classification.accepted && event.args.persist !== true) ||
1176
- (!classification.accepted && classification.mayMutate)) {
1177
- // Flag the workspace unknown only when tool_call_end can resolve
1178
- // it: a bounded check records pass/fail, a file-rewriting command
1179
- // bumps the revision. An unrecognized read-only check (`biome ci`)
1180
- // or a persistent-shell run records nothing at the end, so
1181
- // flagging it left verified work Unverified forever — and
1182
- // autopilot silently refused every later turn.
1183
- this.verificationGate.requireFreshVerification(!classification.accepted && classification.mayMutate);
1184
- }
954
+ if (await this.verificationTracker.recordToolCallStart(event)) {
1185
955
  await this.persistVerificationState();
1186
956
  }
1187
957
  break;
1188
958
  }
1189
959
  case "tool_call_end": {
1190
- const call = this.hookToolCalls.get(event.toolCallId);
960
+ const call = this.verificationTracker.getToolCall(event.toolCallId);
1191
961
  const name = call?.name ?? "";
1192
962
  const args = call?.args;
1193
- this.hookStats.toolCalls += 1;
1194
- if (event.isError)
1195
- this.hookStats.toolFailures += 1;
1196
- this.hookConsecutiveFailures = event.isError ? this.hookConsecutiveFailures + 1 : 0;
1197
- this.hookRepeatedNoProgressCalls = this.hookProgressTracker.record(name, args, event.result, event.isError);
1198
- this.hookCyclicPattern = this.hookCycleDetector.record(name, args, event.result, event.isError);
1199
- // Semantic-loop judge input: a bounded digest of WHAT was attempted and
1200
- // HOW it came out. Args/results are sliced AT RECORD TIME — a write with
1201
- // a 50 KiB payload or a bash dump must never inflate the ring, and the
1202
- // judge needs shapes, not payloads.
1203
- this.hookRecentCalls.push({
1204
- tool: name,
1205
- args: args === undefined ? "" : JSON.stringify(args).slice(0, 300),
1206
- ok: !event.isError,
1207
- result: event.result.slice(0, 400),
1208
- });
1209
- if (this.hookRecentCalls.length > MAX_SEMANTIC_LOOP_CALLS) {
1210
- this.hookRecentCalls.splice(0, this.hookRecentCalls.length - MAX_SEMANTIC_LOOP_CALLS);
1211
- }
1212
- // Only host-observed successful mutations and trustworthy check results
1213
- // affect approval. The model's text is never evidence.
1214
- let verificationChanged = false;
1215
- if (!event.isError && args) {
1216
- if (name === "edit" || name === "write") {
1217
- // Check-owning files (tsconfig.json, pytest.ini, vitest.config.ts …)
1218
- // are tracked even when they are not source code: editing one
1219
- // invalidates earlier check results. A multi-file edit records every file.
1220
- for (const filePath of editTargetPaths(args)) {
1221
- if (isCodeFilePath(filePath) || isCheckOwnFile(filePath)) {
1222
- this.verificationGate.recordMutation(filePath);
1223
- verificationChanged = true;
1224
- }
1225
- }
1226
- }
1227
- }
1228
- if (args && call && name === "bash") {
1229
- const command = typeof args.command === "string" ? args.command : "";
1230
- const classification = classifyVerificationCommand(command);
1231
- if (args.review === true && event.result === REVIEW_REJECTED_BEFORE_START) {
1232
- // The core tool rejected its arguments BEFORE spawning. Never mint
1233
- // a pass, or poison the failed-check ledger with an unexecuted check.
1234
- // A child's output cannot match: bash always prefixes it with Exit code.
1235
- this.verificationGate.recordRejectedCheck(command, REVIEW_REJECTED_BEFORE_START);
1236
- delete call.sourceSnapshot;
1237
- verificationChanged = true;
1238
- }
1239
- else if (classification.accepted || classification.snapshotEligible) {
1240
- // A foreground check that outlived the default budget was moved to
1241
- // the background, not failed: track it to its real exit the same way.
1242
- const autoBackgroundId = event.isError ? undefined : autoBackgroundedId(event.result);
1243
- if ((autoBackgroundId !== undefined || args.run_in_background === true) &&
1244
- !event.isError &&
1245
- args.persist !== true) {
1246
- const id = autoBackgroundId ?? /^ID:\s*(\S+)/m.exec(event.result)?.[1];
1247
- // No parseable ID means the check cannot be tracked to a real exit
1248
- // code — no evidence either way. Recording a FAILURE here made
1249
- // every later green run of a different spelling look owed.
1250
- if (id)
1251
- this.backgroundVerification.set(id, {
1252
- revision: call.revision,
1253
- command,
1254
- ...(classification.snapshotEligible
1255
- ? { sourceSnapshot: call.sourceSnapshot ?? null }
1256
- : {}),
1257
- });
1258
- else if (classification.snapshotEligible)
1259
- this.verificationGate.requireFreshVerification(true);
1260
- }
1261
- else if (args.persist === true) {
1262
- // Persistent-shell checks are not bounded evidence (steering can
1263
- // interleave): neither a pass nor a failure. A recorded failure
1264
- // here blocked approval for sessions that prefer the shell.
1265
- }
1266
- else if (classification.snapshotEligible) {
1267
- await this.finishSnapshotVerification({ command, revision: call.revision, sourceSnapshot: call.sourceSnapshot ?? null }, !event.isError && /^Exit code:\s*0(?:\s|$)/i.test(event.result.trim()));
1268
- verificationChanged = true;
1269
- }
1270
- else {
1271
- if (!event.isError && /^Exit code:\s*0(?:\s|$)/i.test(event.result.trim())) {
1272
- this.verificationGate.recordVerification(call.revision, command);
1273
- }
1274
- else {
1275
- this.verificationGate.recordFailedVerification(command, call.revision);
1276
- }
1277
- verificationChanged = true;
1278
- }
1279
- delete call.sourceSnapshot;
1280
- }
1281
- else if (classification.candidate) {
1282
- // Green but untrusted: remember WHY so the demand can tell the
1283
- // agent which command shape actually clears the gate.
1284
- this.verificationGate.recordRejectedCheck(command, classification.reason);
1285
- }
1286
- }
1287
- if (!event.isError && args && name === "task_output" && typeof args.id === "string") {
1288
- verificationChanged =
1289
- (await this.recordFinishedBackgroundVerification(args.id)) || verificationChanged;
1290
- }
963
+ this.loopMonitor.recordToolEnd(name, args, event.result, event.isError);
964
+ const verificationChanged = await this.verificationTracker.recordToolCallEnd(event, call, this.planModeRef.current);
1291
965
  if (verificationChanged)
1292
966
  await this.persistVerificationState();
1293
967
  // Tool results are what push the run over the review gate, and they all
@@ -1297,7 +971,7 @@ export class AgentSession {
1297
971
  break;
1298
972
  }
1299
973
  case "turn_end":
1300
- this.hookStats.turns = event.turn;
974
+ this.loopMonitor.recordTurn(event.turn);
1301
975
  this.refreshHookArming();
1302
976
  for (let index = this.messages.length - 1; index >= 0; index--) {
1303
977
  const anchor = this.messages[index];
@@ -1474,51 +1148,26 @@ export class AgentSession {
1474
1148
  // Deterministic stuck verdict, computed once and shared: the semantic
1475
1149
  // judge must not spend tokens on a burst the deterministic breaker is
1476
1150
  // about to correct itself.
1477
- const deterministicDecision = evaluateLoopBreak({
1478
- consecutiveFailures: this.hookConsecutiveFailures,
1479
- repeatedNoProgressCalls: this.hookRepeatedNoProgressCalls,
1480
- textRepetitionDetected: detectTextRepetition(this.hookText),
1481
- ...(this.hookCyclicPattern ? { cyclicPattern: this.hookCyclicPattern } : {}),
1482
- });
1151
+ const deterministicDecision = this.loopMonitor.evaluateLoopBreak();
1483
1152
  this.maybeStartSemanticLoopCheck(deterministicDecision.shouldBreak);
1484
1153
  // Two-stage loop-breaker: stage 1 nudges; a FRESH detection after that
1485
1154
  // injects the harsher final stop-and-report prompt. Signals reset after
1486
1155
  // each injection so stage 2 only fires on new evidence.
1487
- if (this.loopBreakInjected < 2) {
1488
- const decision = deterministicDecision;
1489
- if (decision.shouldBreak) {
1490
- const stage = this.loopBreakInjected === 0 ? 1 : 2;
1491
- this.loopBreakInjected = stage;
1492
- this.hookProgressTracker.reset();
1493
- this.hookCycleDetector.reset();
1494
- this.hookCyclicPattern = null;
1495
- this.hookConsecutiveFailures = 0;
1496
- this.hookRepeatedNoProgressCalls = 0;
1497
- // The deterministic breaker owns this burst — a semantic verdict from
1498
- // the same burst must not double-correct on the next poll.
1499
- this.semanticLoop.verdict = null;
1500
- // Clear the text buffer too — otherwise a stage-1 text-repetition
1501
- // trigger still sees the same repeated tail on the next check and
1502
- // escalates to stage 2 on stale evidence.
1503
- this.hookText = "";
1504
- log("INFO", "loop-break", "Injecting loop-break nudge", {
1505
- stage: String(stage),
1506
- reasons: decision.reasons.join(", "),
1507
- });
1508
- this.eventBus.emit("hook", { kind: "loop_break" });
1509
- return [buildLoopBreakMessage(decision.reasons, stage === 2)];
1510
- }
1156
+ const stage = this.loopMonitor.takeLoopBreakStage(deterministicDecision);
1157
+ if (stage !== null) {
1158
+ log("INFO", "loop-break", "Injecting loop-break nudge", {
1159
+ stage: String(stage),
1160
+ reasons: deterministicDecision.reasons.join(", "),
1161
+ });
1162
+ this.eventBus.emit("hook", { kind: "loop_break" });
1163
+ return [buildLoopBreakMessage(deterministicDecision.reasons, stage === 2)];
1511
1164
  }
1512
1165
  // Semantic loop-break: an LLM verdict (started by maybeStartSemanticLoopCheck
1513
1166
  // on a suspicious-but-syntactically-quiet burst) is consumed here, exactly
1514
1167
  // once, with the deterministic breaker getting priority above. Fail-open:
1515
1168
  // no verdict or no-loop verdict injects nothing.
1516
- if (this.semanticLoop.verdict && !this.semanticLoop.injected) {
1517
- const verdict = this.semanticLoop.verdict;
1518
- this.semanticLoop.injected = true;
1519
- this.semanticLoop.verdict = null;
1520
- // The judged burst has been addressed; a fresh burst must re-accumulate.
1521
- this.hookConsecutiveFailures = 0;
1169
+ const verdict = this.loopMonitor.takeSemanticVerdict();
1170
+ if (verdict) {
1522
1171
  log("INFO", "loop-break", "Injecting semantic loop-break steering", {
1523
1172
  reason: verdict.reason,
1524
1173
  });
@@ -1538,34 +1187,26 @@ export class AgentSession {
1538
1187
  * Fire-and-forget: the call runs while the next turn streams, and a finished
1539
1188
  * verdict is consumed by the NEXT steering poll — never blocking a turn. */
1540
1189
  maybeStartSemanticLoopCheck(deterministicBreak) {
1541
- if (!shouldRunSemanticLoopCheck({
1542
- consecutiveFailures: this.hookConsecutiveFailures,
1543
- totalFailures: this.hookStats.toolFailures,
1544
- turns: this.hookStats.turns,
1545
- lastCheckTurn: this.semanticLoop.lastCheckTurn,
1546
- checksUsed: this.semanticLoop.checksUsed,
1547
- checkPending: this.semanticLoop.pending,
1548
- deterministicBreak,
1549
- })) {
1190
+ const monitor = this.loopMonitor;
1191
+ if (!monitor.shouldRunSemanticCheck(deterministicBreak)) {
1550
1192
  return;
1551
1193
  }
1552
- // resetHookState replaces this object on every prompt. A late judge must
1194
+ // resetHookState replaces the run state on every prompt. A late judge must
1553
1195
  // not publish a verdict or consume the next run's budget/cooldown.
1554
- const runState = this.semanticLoop;
1555
- runState.pending = true;
1196
+ const runState = monitor.beginSemanticCheck();
1556
1197
  log("INFO", "loop-break", "Starting semantic loop judge", {
1557
- turn: String(this.hookStats.turns),
1558
- consecutiveFailures: String(this.hookConsecutiveFailures),
1559
- recentCalls: String(this.hookRecentCalls.length),
1198
+ turn: String(monitor.stats.turns),
1199
+ consecutiveFailures: String(monitor.consecutiveFailures),
1200
+ recentCalls: String(monitor.recentCalls.length),
1560
1201
  });
1561
1202
  void (async () => {
1562
1203
  try {
1563
- const prompt = buildSemanticLoopJudgePrompt(this.hookRecentCalls, this.originalRequest);
1204
+ const prompt = buildSemanticLoopJudgePrompt(monitor.recentCalls, this.originalRequest);
1564
1205
  const raw = await (this.opts.semanticLoopJudge?.(prompt) ??
1565
1206
  this.callSemanticLoopJudge(prompt));
1566
1207
  const verdict = parseSemanticLoopVerdict(raw);
1567
- if (this.semanticLoop === runState && verdict?.loop)
1568
- runState.verdict = verdict;
1208
+ if (verdict?.loop)
1209
+ monitor.publishSemanticVerdict(runState, verdict);
1569
1210
  }
1570
1211
  catch (error) {
1571
1212
  // Fail open: judge errors never stop a run. Budget and cooldown are
@@ -1575,11 +1216,7 @@ export class AgentSession {
1575
1216
  });
1576
1217
  }
1577
1218
  finally {
1578
- if (this.semanticLoop === runState) {
1579
- runState.pending = false;
1580
- runState.checksUsed += 1;
1581
- runState.lastCheckTurn = this.hookStats.turns;
1582
- }
1219
+ monitor.settleSemanticCheck(runState);
1583
1220
  }
1584
1221
  })();
1585
1222
  }
@@ -1675,23 +1312,7 @@ export class AgentSession {
1675
1312
  * with — so reuse the same stuck signals that drive the loop-breaker.
1676
1313
  */
1677
1314
  shouldExtendTurnBudget(ctx) {
1678
- const refusals = [];
1679
- const stuck = evaluateLoopBreak({
1680
- consecutiveFailures: this.hookConsecutiveFailures,
1681
- repeatedNoProgressCalls: this.hookRepeatedNoProgressCalls,
1682
- textRepetitionDetected: detectTextRepetition(this.hookText),
1683
- ...(this.hookCyclicPattern ? { cyclicPattern: this.hookCyclicPattern } : {}),
1684
- });
1685
- if (stuck.shouldBreak)
1686
- refusals.push(...stuck.reasons);
1687
- // Stage 2 means the loop-breaker already detected spinning twice and told
1688
- // the agent to stop and report. Do not overrule that with more turns.
1689
- if (this.loopBreakInjected >= 2)
1690
- refusals.push("loop-breaker already escalated");
1691
- const { toolCalls, toolFailures } = this.hookStats;
1692
- if (toolCalls > 0 && toolFailures / toolCalls > TURN_EXTENSION_MAX_FAILURE_RATIO) {
1693
- refusals.push(`${toolFailures}/${toolCalls} tool calls failed`);
1694
- }
1315
+ const refusals = this.loopMonitor.turnExtensionRefusals();
1695
1316
  const granted = refusals.length === 0;
1696
1317
  log("INFO", "turn-budget", granted ? "Extending turn budget" : "Refusing turn-budget extension", {
1697
1318
  turn: String(ctx.turn),
@@ -1727,11 +1348,7 @@ export class AgentSession {
1727
1348
  async getHookFollowUpMessages() {
1728
1349
  // Exit notifications and task_output refer to the same host process record.
1729
1350
  // Do not force an extra polling tool/turn merely to acknowledge a known exit.
1730
- let backgroundChanged = false;
1731
- for (const id of this.backgroundVerification.keys()) {
1732
- backgroundChanged =
1733
- (await this.recordFinishedBackgroundVerification(id)) || backgroundChanged;
1734
- }
1351
+ const backgroundChanged = await this.verificationTracker.settleFinishedBackground();
1735
1352
  if (backgroundChanged)
1736
1353
  await this.persistVerificationState();
1737
1354
  // Edits return immediately; only the completion boundary waits for remaining
@@ -1770,6 +1387,22 @@ export class AgentSession {
1770
1387
  }
1771
1388
  if (diagnosticMessages.length > 0)
1772
1389
  return diagnosticMessages;
1390
+ if (shouldNudgePlanSubmission({
1391
+ enabled: this.opts.planSubmissionGate === true,
1392
+ planMode: this.planModeRef.current,
1393
+ aborted: this.opts.signal?.aborted === true,
1394
+ nudgesThisRun: this.planSubmissionNudges,
1395
+ })) {
1396
+ this.planSubmissionNudges += 1;
1397
+ log("INFO", "plan-gate", "Run stopping in plan mode without a submitted plan; reminding");
1398
+ return [
1399
+ {
1400
+ role: "user",
1401
+ content: buildNotificationSteeringText([PLAN_SUBMISSION_NUDGE]),
1402
+ provenance: { source: "runtime", kind: "notification", visibility: "hidden" },
1403
+ },
1404
+ ];
1405
+ }
1773
1406
  if (this.opts.completionReview) {
1774
1407
  const followUp = await this.opts.completionReview.followUp((request, signal) => this.callCompletionReviewer(request, signal), this.opts.signal);
1775
1408
  await this.persistCompletionReviewState();
@@ -1992,6 +1625,11 @@ export class AgentSession {
1992
1625
  maxTurnToolResultChars: resolveSessionTurnToolResultCharLimit(this.model, this.provider, accountId),
1993
1626
  // Warn when web/MCP output contains instruction-like text (see injection-detect.ts).
1994
1627
  transformToolResult: flagUntrustedToolResult,
1628
+ // A deferred built-in called by its advertised name runs directly
1629
+ // instead of costing a turn on "Unknown tool" then tool_search.
1630
+ ...(options.disableTools
1631
+ ? {}
1632
+ : { resolveTool: (name) => this.promoteDeferredBuiltin(name) }),
1995
1633
  // Self-correction hooks (same as the TUI): loop-break + re-grounding are
1996
1634
  // polled mid-loop; the ideal review is polled when the agent would stop.
1997
1635
  getSteeringMessages: () => this.getHookSteeringMessages(),
@@ -2313,11 +1951,12 @@ export class AgentSession {
2313
1951
  // there avoids tearing down a live stdio child and
2314
1952
  // gambling on a `npx` re-spawn that could fail and drop the tools.
2315
1953
  const glmInvolved = this.provider === "glm" || prevProvider === "glm";
2316
- if (this.mcpManager && glmInvolved) {
1954
+ const mcpManager = this.mcp.manager;
1955
+ if (mcpManager && glmInvolved) {
2317
1956
  // Remove old MCP tools
2318
1957
  this.tools = this.tools.filter((t) => !t.name.startsWith("mcp__"));
2319
1958
  // Disconnect old MCP servers
2320
- await this.mcpManager.dispose();
1959
+ await mcpManager.dispose();
2321
1960
  // Connect new MCP servers for the new provider
2322
1961
  try {
2323
1962
  let apiKey;
@@ -2334,13 +1973,11 @@ export class AgentSession {
2334
1973
  const servers = await getAllMcpServers(this.provider, apiKey, this.cwd, {
2335
1974
  allowProjectScope: this.settingsManager.isProjectTrusted(this.cwd),
2336
1975
  });
2337
- const mcpTools = await this.mcpManager.connectAll(servers);
1976
+ const mcpTools = await mcpManager.connectAll(servers);
2338
1977
  // Drop stale MCP tools from both the live set and deferred catalog before
2339
1978
  // re-adding. Some tools may already have been promoted out of the catalog.
2340
1979
  this.tools = this.tools.filter((t) => !t.name.startsWith("mcp__"));
2341
- this.mcpCatalog?.removeWhere((name) => name.startsWith("mcp__"));
2342
- this.liveMcpTools.clear();
2343
- this.cachedMcpToolServers.clear();
1980
+ this.mcp.clearMcpTools();
2344
1981
  this.addMcpTools(mcpTools);
2345
1982
  }
2346
1983
  catch (err) {
@@ -2383,10 +2020,7 @@ export class AgentSession {
2383
2020
  this.checkpointGeneration = loaded.header.generation ?? 0;
2384
2021
  this.currentLeafId = loaded.header.leafId;
2385
2022
  this.setSessionPath(loaded.path);
2386
- this.kenTurns = this.sessionManager.getKenTurns(loaded.entries, loaded.header.leafId);
2387
- this.autopilotMarkers = this.sessionManager.getAutopilotMarkers(loaded.entries, loaded.header.leafId);
2388
- this.appMarkers = this.sessionManager.getAppMarkers(loaded.entries, loaded.header.leafId);
2389
- this.turnMetrics = this.sessionManager.getTurnMetrics(loaded.entries);
2023
+ this.markers.restore(loaded.entries, loaded.header.leafId);
2390
2024
  this.lastPersistedIndex = this.messages.length;
2391
2025
  this.providerContext = null;
2392
2026
  await this.subAgentManager?.rebindParentSession(this.sessionId);
@@ -2426,10 +2060,7 @@ export class AgentSession {
2426
2060
  await this.persistMessage(message);
2427
2061
  }
2428
2062
  this.lastPersistedIndex = this.messages.length;
2429
- await this.rePersistTurnMetrics();
2430
- await this.rePersistKenTurns();
2431
- await this.rePersistAutopilotMarkers();
2432
- await this.rePersistAppMarkers();
2063
+ await this.markers.rePersistAll();
2433
2064
  await this.persistVerificationState();
2434
2065
  await this.persistAppMarker("compaction", {
2435
2066
  originalCount: result.originalCount,
@@ -2788,12 +2419,17 @@ export class AgentSession {
2788
2419
  });
2789
2420
  }
2790
2421
  async newSession(preserveConversation = false) {
2422
+ // A background post-turn compaction replaces `this.messages` when it
2423
+ // lands. Letting it finish after the reset would swap the old history back
2424
+ // into the fresh session (e.g. an accepted plan's build would inherit the
2425
+ // whole planning conversation and lose the approved-plan prompt).
2426
+ await this.settlePostTurnCompaction();
2791
2427
  this.cacheDiagnostics.reset();
2792
2428
  // Approved-plan execution is a clean checkpoint of the same conversation;
2793
2429
  // explicit new sessions reset the conversation identity.
2794
2430
  if (!preserveConversation) {
2795
2431
  this.verificationGate.reset();
2796
- this.backgroundVerification.clear();
2432
+ this.verificationTracker.clearBackground();
2797
2433
  this.conversationId = "";
2798
2434
  this.checkpointGeneration = 0;
2799
2435
  this.sessionPreview = "";
@@ -2805,10 +2441,7 @@ export class AgentSession {
2805
2441
  // turns / autopilot verdicts / app markers linger in memory, show up in the
2806
2442
  // new session's /history, and get re-persisted into the new file by the
2807
2443
  // next compaction — the cross-session duplicate-marker propagation bug.
2808
- this.kenTurns = [];
2809
- this.autopilotMarkers = [];
2810
- this.appMarkers = [];
2811
- this.turnMetrics = [];
2444
+ this.markers.clear();
2812
2445
  const basePrompt = await this.buildBasePrompt(false, undefined);
2813
2446
  this.baseSystemPrompt = basePrompt;
2814
2447
  this.messages = [{ role: "system", content: this.withSystemPromptTail(basePrompt) }];
@@ -2930,9 +2563,7 @@ export class AgentSession {
2930
2563
  else {
2931
2564
  used = calculateActiveContextTokens(this.messages);
2932
2565
  }
2933
- const costUsd = this.turnMetrics.length > 0 && this.turnMetrics.every((m) => m.cost.status === "known")
2934
- ? this.turnMetrics.reduce((sum, m) => sum + (m.cost.status === "known" ? m.cost.usd : 0), 0)
2935
- : undefined;
2566
+ const costUsd = this.markers.knownCostUsd();
2936
2567
  return costUsd === undefined ? { used, size } : { used, size, costUsd };
2937
2568
  }
2938
2569
  /**
@@ -3056,6 +2687,11 @@ export class AgentSession {
3056
2687
  */
3057
2688
  async setPlanMode(active) {
3058
2689
  this.planModeRef.current = active;
2690
+ // Plan mode always ends with `exit_plan`; a deferred one must be callable
2691
+ // without a tool_search turn. Append-only, like any promotion.
2692
+ if (active && !this.tools.some((t) => t.name === "exit_plan")) {
2693
+ this.tools.push(...this.mcp.promote(["exit_plan"]));
2694
+ }
3059
2695
  // Entering plan mode discards any prior approved-plan contract (a new plan
3060
2696
  // is about to be drafted); exiting keeps it (set explicitly via accept).
3061
2697
  if (active)
@@ -3259,7 +2895,10 @@ export class AgentSession {
3259
2895
  orchestrationPolicyTail() {
3260
2896
  if (this.opts.orchestrationPrompt === false)
3261
2897
  return "";
3262
- return applyAsyncSubagentPolicy("", this.provider, this.model, this.thinkingLevel, this.tools.map((tool) => tool.name)).trim();
2898
+ return applyAsyncSubagentPolicy("", this.provider, this.model, this.thinkingLevel,
2899
+ // Deferred spawn_agent/wait_agent are one call away (resolveTool loads
2900
+ // them on first use), so the policy must still see them.
2901
+ [...this.tools.map((tool) => tool.name), ...this.deferredBuiltinToolNames]).trim();
3263
2902
  }
3264
2903
  /**
3265
2904
  * Re-render the uncached tail of messages[0] in place. The cached prefix is
@@ -3281,12 +2920,7 @@ export class AgentSession {
3281
2920
  return this.messages;
3282
2921
  }
3283
2922
  getTurnMetrics() {
3284
- return this.turnMetrics.map((metric) => ({
3285
- ...metric,
3286
- usage: { ...metric.usage },
3287
- timing: { ...metric.timing },
3288
- cost: { ...metric.cost },
3289
- }));
2923
+ return this.markers.getTurnMetrics();
3290
2924
  }
3291
2925
  async persistTurnMetric(event) {
3292
2926
  if (event.stopReason === "error")
@@ -3313,31 +2947,23 @@ export class AgentSession {
3313
2947
  reason: "No authoritative effective-dated provider pricing is available",
3314
2948
  },
3315
2949
  };
3316
- this.turnMetrics.push(payload);
2950
+ this.markers.addTurnMetric(payload);
3317
2951
  // Internal diagnostics piggyback on the authoritative per-turn metric —
3318
2952
  // one source of truth, and the record flushes to disk every turn.
3319
2953
  this.diagnosticsRecorder?.recordTurnMetric(payload);
3320
- if (this.sessionPath)
3321
- await this.sessionManager.appendTurnMetric(this.sessionPath, payload);
3322
- }
3323
- async rePersistTurnMetrics() {
3324
- if (!this.sessionPath)
3325
- return;
3326
- for (const metric of this.turnMetrics) {
3327
- await this.sessionManager.appendTurnMetric(this.sessionPath, metric);
3328
- }
2954
+ await this.markers.persistTurnMetric(payload);
3329
2955
  }
3330
2956
  /** Ken Kai (mentor) turns recorded against this session, in record order. Used
3331
2957
  * by the host to interleave Ken's advisory exchanges back into the transcript
3332
2958
  * on resume. Never part of the LLM message history. */
3333
2959
  getKenTurns() {
3334
- return this.kenTurns;
2960
+ return this.markers.getKenTurns();
3335
2961
  }
3336
2962
  /** Autopilot verdict markers recorded against this session, in record order.
3337
2963
  * Used by the host to interleave the auto-review loop's markers back into
3338
2964
  * the transcript on resume, mirroring `getKenTurns`. */
3339
2965
  getAutopilotMarkers() {
3340
- return this.autopilotMarkers;
2966
+ return this.markers.getAutopilotMarkers();
3341
2967
  }
3342
2968
  /** Non-system messages that are actually on disk. Transcript markers anchor
3343
2969
  * against this (not the in-memory list, which can run ahead after a failed
@@ -3352,15 +2978,7 @@ export class AgentSession {
3352
2978
  * file, so the new file carries positions that match its own transcript.
3353
2979
  */
3354
2980
  remapMarkerAnchors(remap) {
3355
- if (!remap)
3356
- return;
3357
- const move = (payload) => ({
3358
- ...payload,
3359
- afterMessageCount: remapAnchorForCompaction(payload.afterMessageCount, remap),
3360
- });
3361
- this.kenTurns = this.kenTurns.map(move);
3362
- this.autopilotMarkers = this.autopilotMarkers.map(move);
3363
- this.appMarkers = this.appMarkers.map(move);
2981
+ this.markers.remapAnchors(remap);
3364
2982
  }
3365
2983
  /**
3366
2984
  * Record one Ken Kai (mentor agent) turn against this build session: the
@@ -3373,39 +2991,7 @@ export class AgentSession {
3373
2991
  * appendEntry's own handling.
3374
2992
  */
3375
2993
  async persistKenTurn(question, reply) {
3376
- const afterMessageCount = this.persistedTranscriptCount();
3377
- const payload = { version: 1, question, reply, afterMessageCount };
3378
- this.kenTurns.push(payload);
3379
- if (!this.sessionPath)
3380
- return;
3381
- const entry = {
3382
- type: "custom",
3383
- kind: KEN_TURN_CUSTOM_KIND,
3384
- id: crypto.randomUUID(),
3385
- parentId: null,
3386
- timestamp: new Date().toISOString(),
3387
- data: payload,
3388
- };
3389
- await this.sessionManager.appendEntry(this.sessionPath, entry);
3390
- }
3391
- /** Re-append the in-memory Ken turns to the current session file. Called after
3392
- * a continuation/compaction file is created so Ken's advisory history isn't
3393
- * lost when the session is rewritten (those rewrites only re-persist
3394
- * messages). Each turn keeps its original `afterMessageCount` anchor. */
3395
- async rePersistKenTurns() {
3396
- if (!this.sessionPath)
3397
- return;
3398
- for (const payload of this.kenTurns) {
3399
- const entry = {
3400
- type: "custom",
3401
- kind: KEN_TURN_CUSTOM_KIND,
3402
- id: crypto.randomUUID(),
3403
- parentId: null,
3404
- timestamp: new Date().toISOString(),
3405
- data: stripRecordedPosition(payload),
3406
- };
3407
- await this.sessionManager.appendEntry(this.sessionPath, entry);
3408
- }
2994
+ await this.markers.recordKenTurn(question, reply, this.persistedTranscriptCount());
3409
2995
  }
3410
2996
  /**
3411
2997
  * Record one autopilot verdict marker (prompted / done / human / capped)
@@ -3415,23 +3001,6 @@ export class AgentSession {
3415
3001
  * instead of dropping the marker or falling back to a raw verdict string.
3416
3002
  * No-op persistence for transient sessions (kept in memory only).
3417
3003
  */
3418
- async recordFinishedBackgroundVerification(id) {
3419
- const started = this.backgroundVerification.get(id);
3420
- const proc = this.processManager?.list().find((entry) => entry.id === id);
3421
- if (!started || !proc || proc.exitCode === null)
3422
- return false;
3423
- if (started.sourceSnapshot !== undefined) {
3424
- await this.finishSnapshotVerification(started, proc.exitCode === 0);
3425
- }
3426
- else if (proc.exitCode === 0) {
3427
- this.verificationGate.recordVerification(started.revision, started.command);
3428
- }
3429
- else {
3430
- this.verificationGate.recordFailedVerification(started.command, started.revision);
3431
- }
3432
- this.backgroundVerification.delete(id);
3433
- return true;
3434
- }
3435
3004
  getVerificationEvidence() {
3436
3005
  return this.verificationGate.evidence();
3437
3006
  }
@@ -3443,39 +3012,8 @@ export class AgentSession {
3443
3012
  evidence: this.verificationGate.evidence("run"),
3444
3013
  };
3445
3014
  }
3446
- async finishSnapshotVerification(check, passed) {
3447
- const after = check.sourceSnapshot
3448
- ? await captureVerificationSnapshot(this.opts.cwd, [...this.hookFileEditCounts.keys()])
3449
- : null;
3450
- if (after === null || after !== check.sourceSnapshot) {
3451
- this.verificationGate.requireFreshVerification(true);
3452
- this.verificationGate.recordRejectedCheck(check.command, after === null
3453
- ? "Workspace inputs could not be compared; run a read-only check after the command"
3454
- : "Command changed workspace inputs; run checks against the changed source");
3455
- if (!passed)
3456
- this.verificationGate.recordFailedVerification(check.command);
3457
- }
3458
- else if (passed) {
3459
- const classification = classifyVerificationCommand(check.command);
3460
- if (classification.snapshotPreserveOnly) {
3461
- this.verificationGate.recordRejectedCheck(check.command, classification.reason);
3462
- }
3463
- else {
3464
- this.verificationGate.recordVerification(check.revision, check.command);
3465
- }
3466
- }
3467
- else {
3468
- this.verificationGate.recordFailedVerification(check.command, check.revision);
3469
- }
3470
- }
3471
3015
  getVerificationProblem() {
3472
- if ([...this.hookToolCalls.values()].some((call) => call.sourceSnapshot !== undefined)) {
3473
- return "Unverified: a build is still running or its workspace comparison is pending.";
3474
- }
3475
- return (this.verificationGate.verificationProblem() ??
3476
- (this.backgroundVerification.size > 0
3477
- ? "Unverified: a background check is still running or its result has not been confirmed."
3478
- : null));
3016
+ return this.verificationTracker.verificationProblem();
3479
3017
  }
3480
3018
  async persistCompletionReviewState() {
3481
3019
  if (!this.sessionPath || !this.opts.completionReview)
@@ -3508,50 +3046,13 @@ export class AgentSession {
3508
3046
  await this.sessionManager.appendEntry(this.sessionPath, entry);
3509
3047
  }
3510
3048
  async persistAutopilotMarker(phase, extra) {
3511
- const afterMessageCount = this.persistedTranscriptCount();
3512
- const payload = {
3513
- version: 1,
3514
- phase,
3515
- afterMessageCount,
3516
- ...(extra?.reason !== undefined ? { reason: extra.reason } : {}),
3517
- ...(extra?.body !== undefined ? { body: extra.body } : {}),
3518
- };
3519
- this.autopilotMarkers.push(payload);
3520
- if (!this.sessionPath)
3521
- return;
3522
- const entry = {
3523
- type: "custom",
3524
- kind: AUTOPILOT_MARKER_CUSTOM_KIND,
3525
- id: crypto.randomUUID(),
3526
- parentId: null,
3527
- timestamp: new Date().toISOString(),
3528
- data: payload,
3529
- };
3530
- await this.sessionManager.appendEntry(this.sessionPath, entry);
3531
- }
3532
- /** Re-append the in-memory autopilot markers to the current session file.
3533
- * Mirrors `rePersistKenTurns` — called after a continuation/compaction file
3534
- * is created so the auto-review history survives the rewrite. */
3535
- async rePersistAutopilotMarkers() {
3536
- if (!this.sessionPath)
3537
- return;
3538
- for (const payload of this.autopilotMarkers) {
3539
- const entry = {
3540
- type: "custom",
3541
- kind: AUTOPILOT_MARKER_CUSTOM_KIND,
3542
- id: crypto.randomUUID(),
3543
- parentId: null,
3544
- timestamp: new Date().toISOString(),
3545
- data: stripRecordedPosition(payload),
3546
- };
3547
- await this.sessionManager.appendEntry(this.sessionPath, entry);
3548
- }
3049
+ await this.markers.recordAutopilotMarker(phase, extra, this.persistedTranscriptCount());
3549
3050
  }
3550
3051
  /** App transcript markers recorded against this session, in record order.
3551
3052
  * Used by the host to interleave display-only rows (plan banner, task
3552
3053
  * header, errors, user-bubble hints) back into the transcript on resume. */
3553
3054
  getAppMarkers() {
3554
- return this.appMarkers;
3055
+ return this.markers.getAppMarkers();
3555
3056
  }
3556
3057
  /**
3557
3058
  * Record one app transcript marker (display-only row) against this session.
@@ -3563,20 +3064,7 @@ export class AgentSession {
3563
3064
  * No-op persistence for transient sessions.
3564
3065
  */
3565
3066
  async persistAppMarker(kind, data, anchorOffset = 0) {
3566
- const afterMessageCount = this.persistedTranscriptCount() + anchorOffset;
3567
- const payload = { version: 1, kind, afterMessageCount, data };
3568
- this.appMarkers.push(payload);
3569
- if (!this.sessionPath)
3570
- return;
3571
- const entry = {
3572
- type: "custom",
3573
- kind: APP_MARKER_CUSTOM_KIND,
3574
- id: crypto.randomUUID(),
3575
- parentId: null,
3576
- timestamp: new Date().toISOString(),
3577
- data: payload,
3578
- };
3579
- await this.sessionManager.appendEntry(this.sessionPath, entry);
3067
+ await this.markers.recordAppMarker(kind, data, this.persistedTranscriptCount() + anchorOffset);
3580
3068
  }
3581
3069
  /**
3582
3070
  * Open the run journal for one `RunLifecycle` generation.
@@ -3605,24 +3093,6 @@ export class AgentSession {
3605
3093
  outcome,
3606
3094
  });
3607
3095
  }
3608
- /** Re-append the in-memory app markers to the current session file. Mirrors
3609
- * `rePersistKenTurns` — called after a continuation/compaction file is
3610
- * created so display-only rows survive the rewrite. */
3611
- async rePersistAppMarkers() {
3612
- if (!this.sessionPath)
3613
- return;
3614
- for (const payload of this.appMarkers) {
3615
- const entry = {
3616
- type: "custom",
3617
- kind: APP_MARKER_CUSTOM_KIND,
3618
- id: crypto.randomUUID(),
3619
- parentId: null,
3620
- timestamp: new Date().toISOString(),
3621
- data: stripRecordedPosition(payload),
3622
- };
3623
- await this.sessionManager.appendEntry(this.sessionPath, entry);
3624
- }
3625
- }
3626
3096
  /**
3627
3097
  * Rewrite a draft prompt into a tighter, terminology-correct version using
3628
3098
  * the ACTIVE provider/model. A stateless one-off LLM call (no agent loop, no
@@ -3864,7 +3334,7 @@ export class AgentSession {
3864
3334
  this.stopBackgroundProcesses();
3865
3335
  this.lspManager?.shutdownAll();
3866
3336
  this.debugManager?.shutdown();
3867
- await Promise.all([this.subAgentManager?.shutdownAll(), this.mcpManager?.dispose()]);
3337
+ await Promise.all([this.subAgentManager?.shutdownAll(), this.mcp.manager?.dispose()]);
3868
3338
  await this.extensionLoader.deactivateAll();
3869
3339
  this.setSessionPath("");
3870
3340
  this.eventBus.removeAllListeners();
@@ -3909,7 +3379,7 @@ export class AgentSession {
3909
3379
  .reverse()
3910
3380
  .find((entry) => entry.type === "custom" && entry.kind === COMPLETION_REVIEW_STATE_KIND);
3911
3381
  this.opts.completionReview?.restore(savedCompletionReview?.type === "custom" ? savedCompletionReview.data : null);
3912
- this.backgroundVerification.clear();
3382
+ this.verificationTracker.clearBackground();
3913
3383
  const savedVerification = [...loaded.entries]
3914
3384
  .reverse()
3915
3385
  .find((entry) => entry.type === "custom" && entry.kind === VERIFICATION_STATE_KIND);
@@ -3941,12 +3411,8 @@ export class AgentSession {
3941
3411
  // The leaf is passed so each marker also carries its FILE-order position,
3942
3412
  // the fallback used when a legacy anchor is out of range (see
3943
3413
  // RecordedPosition).
3944
- this.kenTurns = this.sessionManager.getKenTurns(loaded.entries, loaded.header.leafId);
3945
- // Restore autopilot verdict markers the same way (not on the message DAG).
3946
- this.autopilotMarkers = this.sessionManager.getAutopilotMarkers(loaded.entries, loaded.header.leafId);
3947
- // Restore app transcript markers (plan banner / task header / errors / hints).
3948
- this.appMarkers = this.sessionManager.getAppMarkers(loaded.entries, loaded.header.leafId);
3949
- this.turnMetrics = this.sessionManager.getTurnMetrics(loaded.entries);
3414
+ // Autopilot verdicts, app markers and turn metrics are restored the same way.
3415
+ this.markers.restore(loaded.entries, loaded.header.leafId);
3950
3416
  // A run that opened the journal and never closed it died mid-flight. Read
3951
3417
  // it here, before anything rewrites the file, and report it once the
3952
3418
  // transcript is in place.
@@ -4146,13 +3612,4 @@ export class AgentSession {
4146
3612
  }
4147
3613
  }
4148
3614
  }
4149
- /** Expand a leading `~` so `/import ~/.codex/...` works from any shell. */
4150
- function resolveHomePath(filePath) {
4151
- if (filePath === "~")
4152
- return os.homedir();
4153
- if (filePath.startsWith("~/") || filePath.startsWith("~\\")) {
4154
- return path.join(os.homedir(), filePath.slice(2));
4155
- }
4156
- return filePath;
4157
- }
4158
3615
  //# sourceMappingURL=agent-session.js.map