@kenkaiiii/ggcoder 5.74.3 → 5.75.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 (215) 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/repo-polls.d.ts +15 -0
  65. package/dist/app-sidecar/repo-polls.js +25 -0
  66. package/dist/app-sidecar/repo-polls.js.map +1 -0
  67. package/dist/app-sidecar/route-context.d.ts +145 -0
  68. package/dist/app-sidecar/route-context.js +2 -0
  69. package/dist/app-sidecar/route-context.js.map +1 -0
  70. package/dist/app-sidecar/run-control-routes.d.ts +3 -0
  71. package/dist/app-sidecar/run-control-routes.js +430 -0
  72. package/dist/app-sidecar/run-control-routes.js.map +1 -0
  73. package/dist/app-sidecar/session-routes.d.ts +3 -0
  74. package/dist/app-sidecar/session-routes.js +115 -0
  75. package/dist/app-sidecar/session-routes.js.map +1 -0
  76. package/dist/app-sidecar/session-types.d.ts +39 -0
  77. package/dist/app-sidecar/session-types.js +5 -0
  78. package/dist/app-sidecar/session-types.js.map +1 -0
  79. package/dist/app-sidecar/task-routes.d.ts +3 -0
  80. package/dist/app-sidecar/task-routes.js +149 -0
  81. package/dist/app-sidecar/task-routes.js.map +1 -0
  82. package/dist/app-sidecar/usage.d.ts +19 -0
  83. package/dist/app-sidecar/usage.js +153 -0
  84. package/dist/app-sidecar/usage.js.map +1 -0
  85. package/dist/app-sidecar/workspace-routes.d.ts +3 -0
  86. package/dist/app-sidecar/workspace-routes.js +244 -0
  87. package/dist/app-sidecar/workspace-routes.js.map +1 -0
  88. package/dist/app-sidecar.js +467 -3775
  89. package/dist/app-sidecar.js.map +1 -1
  90. package/dist/cli.js +2 -2
  91. package/dist/cli.js.map +1 -1
  92. package/dist/config.js +1 -1
  93. package/dist/core/acceptance-checks.js +3 -9
  94. package/dist/core/acceptance-checks.js.map +1 -1
  95. package/dist/core/agent-session/helpers.d.ts +26 -0
  96. package/dist/core/agent-session/helpers.js +47 -0
  97. package/dist/core/agent-session/helpers.js.map +1 -0
  98. package/dist/core/agent-session/mcp-tools.d.ts +68 -0
  99. package/dist/core/agent-session/mcp-tools.js +195 -0
  100. package/dist/core/agent-session/mcp-tools.js.map +1 -0
  101. package/dist/core/agent-session/run-loop-monitor.d.ts +63 -0
  102. package/dist/core/agent-session/run-loop-monitor.js +164 -0
  103. package/dist/core/agent-session/run-loop-monitor.js.map +1 -0
  104. package/dist/core/agent-session/transcript-markers.d.ts +57 -0
  105. package/dist/core/agent-session/transcript-markers.js +166 -0
  106. package/dist/core/agent-session/transcript-markers.js.map +1 -0
  107. package/dist/core/agent-session/verification-tracker.d.ts +46 -0
  108. package/dist/core/agent-session/verification-tracker.js +246 -0
  109. package/dist/core/agent-session/verification-tracker.js.map +1 -0
  110. package/dist/core/agent-session.d.ts +21 -87
  111. package/dist/core/agent-session.js +141 -712
  112. package/dist/core/agent-session.js.map +1 -1
  113. package/dist/core/auth-providers.js +3 -3
  114. package/dist/core/auth-providers.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/skills.js +6 -9
  132. package/dist/core/skills.js.map +1 -1
  133. package/dist/core/style-packs/index.js +2 -11
  134. package/dist/core/style-packs/index.js.map +1 -1
  135. package/dist/core/style-packs/packs.d.ts +3 -12
  136. package/dist/core/style-packs/packs.js +132 -173
  137. package/dist/core/style-packs/packs.js.map +1 -1
  138. package/dist/system-prompt.js +55 -78
  139. package/dist/system-prompt.js.map +1 -1
  140. package/dist/tools/ask-user.js +13 -74
  141. package/dist/tools/ask-user.js.map +1 -1
  142. package/dist/tools/bash.js +13 -78
  143. package/dist/tools/bash.js.map +1 -1
  144. package/dist/tools/checklist.d.ts +2 -10
  145. package/dist/tools/checklist.js +7 -39
  146. package/dist/tools/checklist.js.map +1 -1
  147. package/dist/tools/code-nav.js +7 -29
  148. package/dist/tools/code-nav.js.map +1 -1
  149. package/dist/tools/edit.d.ts +3 -9
  150. package/dist/tools/edit.js +92 -65
  151. package/dist/tools/edit.js.map +1 -1
  152. package/dist/tools/enter-plan.js +2 -7
  153. package/dist/tools/enter-plan.js.map +1 -1
  154. package/dist/tools/exit-plan.js +2 -3
  155. package/dist/tools/exit-plan.js.map +1 -1
  156. package/dist/tools/find.js +3 -4
  157. package/dist/tools/find.js.map +1 -1
  158. package/dist/tools/grep.js +6 -18
  159. package/dist/tools/grep.js.map +1 -1
  160. package/dist/tools/ls.js +3 -3
  161. package/dist/tools/ls.js.map +1 -1
  162. package/dist/tools/prompt-hints.js +34 -47
  163. package/dist/tools/prompt-hints.js.map +1 -1
  164. package/dist/tools/read.js +8 -20
  165. package/dist/tools/read.js.map +1 -1
  166. package/dist/tools/search-code.js +4 -12
  167. package/dist/tools/search-code.js.map +1 -1
  168. package/dist/tools/skill.js +13 -7
  169. package/dist/tools/skill.js.map +1 -1
  170. package/dist/tools/subagent-control.js +7 -22
  171. package/dist/tools/subagent-control.js.map +1 -1
  172. package/dist/tools/subagent.js +4 -7
  173. package/dist/tools/subagent.js.map +1 -1
  174. package/dist/tools/task-output.js +4 -15
  175. package/dist/tools/task-output.js.map +1 -1
  176. package/dist/tools/task-send.js +5 -17
  177. package/dist/tools/task-send.js.map +1 -1
  178. package/dist/tools/task-stop.js +2 -2
  179. package/dist/tools/task-stop.js.map +1 -1
  180. package/dist/tools/tool-search.js +2 -7
  181. package/dist/tools/tool-search.js.map +1 -1
  182. package/dist/tools/tool-tiers.d.ts +17 -4
  183. package/dist/tools/tool-tiers.js +38 -15
  184. package/dist/tools/tool-tiers.js.map +1 -1
  185. package/dist/tools/web-fetch.js +7 -33
  186. package/dist/tools/web-fetch.js.map +1 -1
  187. package/dist/tools/write.js +3 -4
  188. package/dist/tools/write.js.map +1 -1
  189. package/dist/ui/components/Footer.js +1 -2
  190. package/dist/ui/components/Footer.js.map +1 -1
  191. package/dist/utils/shared-poll.d.ts +37 -0
  192. package/dist/utils/shared-poll.js +104 -0
  193. package/dist/utils/shared-poll.js.map +1 -0
  194. package/package.json +7 -4
  195. package/dist/core/api-benchmark.d.ts +0 -63
  196. package/dist/core/api-benchmark.js +0 -381
  197. package/dist/core/api-benchmark.js.map +0 -1
  198. package/dist/core/bash-spawn-benchmark.d.ts +0 -1
  199. package/dist/core/bash-spawn-benchmark.js +0 -139
  200. package/dist/core/bash-spawn-benchmark.js.map +0 -1
  201. package/dist/core/cache-warm-benchmark.d.ts +0 -21
  202. package/dist/core/cache-warm-benchmark.js +0 -170
  203. package/dist/core/cache-warm-benchmark.js.map +0 -1
  204. package/dist/core/fast-apply-benchmark.d.ts +0 -25
  205. package/dist/core/fast-apply-benchmark.js +0 -284
  206. package/dist/core/fast-apply-benchmark.js.map +0 -1
  207. package/dist/core/hashline-edit-benchmark.d.ts +0 -88
  208. package/dist/core/hashline-edit-benchmark.js +0 -518
  209. package/dist/core/hashline-edit-benchmark.js.map +0 -1
  210. package/dist/core/semantic-search-benchmark.d.ts +0 -36
  211. package/dist/core/semantic-search-benchmark.js +0 -211
  212. package/dist/core/semantic-search-benchmark.js.map +0 -1
  213. package/dist/core/speed-benchmark.d.ts +0 -132
  214. package/dist/core/speed-benchmark.js +0 -410
  215. package/dist/core/speed-benchmark.js.map +0 -1
@@ -13,31 +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";
29
28
  import { PLAN_SUBMISSION_NUDGE, shouldNudgePlanSubmission } from "./plan-submission-gate.js";
30
29
  import { buildSubAgentCompletionFollowUp } from "./subagent-manager.js";
31
30
  import { applyAsyncSubagentPolicy } from "./subagent-policy.js";
32
31
  import { resolveResponsesLite, resolveStrictTools, } from "./codex-request-shape.js";
33
32
  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";
33
+ import { getAllMcpServers } from "./mcp/index.js";
37
34
  import { CONTEXT_LIMITS, resolveSessionContextLimits, } from "./context-limits.js";
38
- import { McpCatalogCache } from "./mcp/catalog-cache.js";
39
35
  import { describeDropped, importForeignSession, } from "./foreign-session-import.js";
40
- import { createToolSearchTool } from "../tools/tool-search.js";
41
36
  import { createSessionStatsTool } from "../tools/session-stats.js";
42
37
  import { createDiagnoseCommand, isInternalDiagnosticsEnabled, SessionDiagnosticsRecorder, } from "./internal-diagnostics.js";
43
38
  import { log } from "./logger.js";
@@ -54,15 +49,13 @@ import { pruneStaleToolResults } from "./compaction/tool-result-pruner.js";
54
49
  import { discoverAgents } from "./agents.js";
55
50
  import { enhancePrompt } from "../utils/prompt-enhancer.js";
56
51
  import { detectLanguages, detectProjectStack } from "./language-detector.js";
57
- import { evaluateLoopBreak, buildLoopBreakMessage, CycleDetector, ToolCallProgressTracker, detectTextRepetition, } from "./loop-breaker.js";
52
+ import { buildLoopBreakMessage } from "./loop-breaker.js";
58
53
  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";
54
+ import { buildSemanticLoopJudgePrompt, buildSemanticLoopMessage, parseSemanticLoopVerdict, SEMANTIC_LOOP_JUDGE_TIMEOUT_MS, withJudgeTimeout, } from "./semantic-loop-check.js";
60
55
  import { buildEnvDeltaMessage } from "./env-delta.js";
61
56
  import { wrapSteeringText, buildNotificationSteeringText, STEERING_PREFIX } from "./steering.js";
62
57
  import { AgentNotificationQueue } from "./agent-notifications.js";
63
- import { VerificationGate, isCheckOwnFile, isCodeFilePath, VERIFICATION_STATE_KIND, isVerificationCommand, } from "./verification-gate.js";
64
- import { classifyVerificationCommand } from "./verification-evidence.js";
65
- import { captureVerificationSnapshot } from "./verification-snapshot.js";
58
+ import { VerificationGate, VERIFICATION_STATE_KIND } from "./verification-gate.js";
66
59
  import { findUserSessionPrompt, getUserSessionPrompt } from "./session-preview.js";
67
60
  import { normalizeMessageImages } from "./message-images.js";
68
61
  import { loadStreamRules } from "./stream-rules.js";
@@ -70,41 +63,12 @@ import crypto from "node:crypto";
70
63
  import fs from "node:fs/promises";
71
64
  import os from "node:os";
72
65
  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
- }
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";
108
72
  // ── Agent Session ──────────────────────────────────────────
109
73
  export class AgentSession {
110
74
  eventBus = new EventBus();
@@ -114,24 +78,10 @@ export class AgentSession {
114
78
  sessionManager;
115
79
  extensionLoader = new ExtensionLoader();
116
80
  messages = [];
117
- // Ken Kai (mentor agent) turns recorded against this build session. Advisory
118
- // only — NEVER part of `messages` (GG 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
- kenTurns = [];
123
- // Autopilot Ken (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 kenTurns — advisory only,
126
- // persisted + reloaded so a resumed session shows the identical Ken 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 kenTurns —
131
- // display only, persisted + reloaded so a resumed session shows the same
132
- // transcript rows the live run showed.
133
- appMarkers = [];
134
- 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);
135
85
  cacheDiagnostics = new CacheDiagnostics();
136
86
  /** Internal-only (GG_INTERNAL): live per-session cost/reliability recorder.
137
87
  * Absent entirely in public builds — see core/internal-diagnostics.ts. */
@@ -150,25 +100,8 @@ export class AgentSession {
150
100
  // ── Self-correction hook state (mirrors the TUI's useAgentLoop refs) ──
151
101
  // Reset at the start of every run; observed from the event stream; read by
152
102
  // 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;
103
+ loopMonitor = new RunLoopMonitor();
165
104
  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
105
  /**
173
106
  * The environment as the cached system prompt currently describes it.
174
107
  * Re-recorded on every prompt build, so a rebuild (e.g. `/add-dir`) needs no
@@ -184,6 +117,8 @@ export class AgentSession {
184
117
  /** Verification evidence: code edited this run, and what has proved it since.
185
118
  * Passive tracking only — it feeds run status and autopilot, never a turn. */
186
119
  verificationGate = new VerificationGate();
120
+ /** Host-observed evidence (edited files, in-flight and background checks) feeding the gate. */
121
+ verificationTracker;
187
122
  /** Mirror of the last `hook_armed` value, so the event fires only on an edge. */
188
123
  preFinalArmed = false;
189
124
  compactionOccurred = false;
@@ -249,9 +184,8 @@ export class AgentSession {
249
184
  this.lspManager?.clearPendingDiagnostics();
250
185
  void this.subAgentManager?.interruptAll();
251
186
  };
252
- mcpManager;
253
- /** Deferred MCP tools awaiting discovery via tool_search. */
254
- mcpCatalog;
187
+ /** MCP client, deferred tool_search catalog and cached-stub reconcile state. */
188
+ mcp = new McpToolRegistry(() => this.tools, () => this.contextLimits);
255
189
  /** Resolved prompt-injection byte budgets (contextLimits setting). */
256
190
  contextLimits = CONTEXT_LIMITS;
257
191
  /**
@@ -260,11 +194,6 @@ export class AgentSession {
260
194
  * can discover and promote them; a promoted name drops out of this list.
261
195
  */
262
196
  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
197
  provider;
269
198
  model;
270
199
  cwd;
@@ -337,6 +266,7 @@ export class AgentSession {
337
266
  opts;
338
267
  constructor(options) {
339
268
  this.opts = options;
269
+ this.verificationTracker = new VerificationTracker(this.verificationGate, options.cwd, () => this.processManager?.list() ?? []);
340
270
  this.provider = options.provider;
341
271
  this.model = options.model;
342
272
  this.cwd = options.cwd;
@@ -438,7 +368,7 @@ export class AgentSession {
438
368
  authStorage: this.authStorage,
439
369
  onFileMutated: (filePath) => {
440
370
  const relative = path.relative(this.cwd, filePath) || path.basename(filePath);
441
- this.hookFileEditCounts.set(relative, (this.hookFileEditCounts.get(relative) ?? 0) + 1);
371
+ this.verificationTracker.recordFileMutated(relative);
442
372
  },
443
373
  // Lazy — sessionId/model/provider can change after createTools() runs, so
444
374
  // sub-agent spawns read the current parent state at execution time.
@@ -485,6 +415,10 @@ export class AgentSession {
485
415
  // every request. Allow-listed sessions keep the eager path — their fixed
486
416
  // tool expectations predate the catalog, and tool_search isn't allow-listed.
487
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();
488
422
  const { core, deferred } = partitionToolsByTier(this.tools);
489
423
  if (deferred.length > 0) {
490
424
  // Append-only: `core` preserves the original relative order and
@@ -492,10 +426,7 @@ export class AgentSession {
492
426
  // sits inside the cached prefix stays byte-stable across turns.
493
427
  this.tools = core;
494
428
  this.deferredBuiltinToolNames = deferred.map((t) => t.name);
495
- this.mcpCatalog ??= new DeferredToolCatalog(this.contextLimits);
496
- this.mcpCatalog.add(deferred);
497
- this.ensureToolSearchTool();
498
- this.promoteWaitAgentAfterSpawn();
429
+ this.mcp.addDeferred(deferred);
499
430
  }
500
431
  }
501
432
  this.rebuildReadTool = rebuildReadTool;
@@ -513,8 +444,7 @@ export class AgentSession {
513
444
  // its listening handshake until this resolves), `backgroundMcpConnect`
514
445
  // moves the connect off the critical path so the session becomes usable
515
446
  // immediately and tools are appended whenever the servers come up.
516
- this.mcpManager = new MCPClientManager({
517
- catalogCache: this.mcpCatalogCache,
447
+ this.mcp.createManager({
518
448
  modernProtocol: this.settingsManager.get("mcpModernProtocol"),
519
449
  onElicit: this.opts.onMcpElicit,
520
450
  });
@@ -663,7 +593,8 @@ export class AgentSession {
663
593
  * turn, so background-connected servers become available on the next prompt.
664
594
  */
665
595
  async connectMcpServers() {
666
- if (!this.mcpManager)
596
+ const mcpManager = this.mcp.manager;
597
+ if (!mcpManager)
667
598
  return;
668
599
  // Allow-listed (read-only advisory) sessions enforce a fixed tool set by
669
600
  // name. An MCP server is only connected when its name is explicitly
@@ -699,7 +630,7 @@ export class AgentSession {
699
630
  // empty catalog and tool_search would answer "the catalog is empty" for
700
631
  // capabilities that genuinely exist — a wrong answer, not a slow one.
701
632
  await this.seedMcpCatalogFromCache(servers);
702
- const connected = await this.mcpManager.connectAll(servers);
633
+ const connected = await mcpManager.connectAll(servers);
703
634
  // Defense-in-depth: even from a whitelisted server, only push tools that
704
635
  // pass the allow-list (no-op when there's no allow-list).
705
636
  const mcpTools = this.opts.allowedTools
@@ -738,170 +669,65 @@ export class AgentSession {
738
669
  * pushed eagerly when the user opted out.
739
670
  * Allow-listed sessions (Ken) always get the eager path — their fixed tool
740
671
  * expectations predate the catalog, and tool_search isn't allow-listed.
741
- * Promotion pushes onto the live `this.tools` array the running agent loop
742
- * re-reads every turn, so promoted tools are callable on the next step.
743
672
  */
744
673
  addMcpTools(mcpTools) {
745
- if (mcpTools.length === 0)
746
- return;
747
- for (const tool of mcpTools) {
748
- this.liveMcpTools.set(tool.name, tool);
749
- this.cachedMcpToolServers.delete(tool.name);
750
- }
751
674
  const defer = !this.opts.allowedTools && this.settingsManager.get("deferredMcpTools");
752
- if (!defer) {
753
- // Eager path bypasses the catalog, so budget descriptions here too.
754
- this.replaceOrPushTools(mcpTools.map((tool) => clampMcpToolDescription(tool, this.contextLimits)));
755
- return;
756
- }
757
- this.mcpCatalog ??= new DeferredToolCatalog(this.contextLimits);
758
- // `add` is name-keyed, so live definitions replace cached stubs in place.
759
- this.mcpCatalog.add(mcpTools);
760
- // A stub the model already promoted lives in `this.tools`; swap it for the
761
- // live tool so later calls dispatch directly instead of through the stub.
762
- this.replaceLivePromotedTools(mcpTools);
763
- this.ensureToolSearchTool();
675
+ this.mcp.addMcpTools(mcpTools, defer);
764
676
  }
765
677
  /**
766
- * Register `tool_search` once. Promotion of a cached-only entry waits for its
767
- * server so the model is told immediately when that capability turns out to
768
- * be unreachable, instead of promoting a tool that fails on first call.
769
- *
770
- * The catalog is created on demand rather than required up front: deferred
771
- * built-in tools populate it with zero MCP servers connected, so gating
772
- * 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.
773
686
  */
774
- ensureToolSearchTool() {
775
- this.mcpCatalog ??= new DeferredToolCatalog(this.contextLimits);
776
- if (this.tools.some((t) => t.name === "tool_search"))
777
- return;
778
- this.tools.push(createToolSearchTool(this.mcpCatalog, (promoted) => {
779
- this.tools.push(...promoted);
780
- }, async (toolName) => {
781
- if (this.liveMcpTools.has(toolName))
782
- return undefined;
783
- const serverName = this.cachedMcpToolServers.get(toolName);
784
- if (!serverName)
785
- return undefined;
786
- const outcome = (await this.mcpManager?.whenConnected(serverName)) ?? {
787
- ok: false,
788
- error: "MCP is disabled for this session",
789
- };
790
- return outcome.ok
791
- ? { serverName, ok: true }
792
- : { serverName, ok: false, error: outcome.error };
793
- }, 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);
794
690
  }
795
- /**
796
- * `wait_agent` is deferred, yet nearly every `spawn_agent` is followed by it,
797
- * so the model spent a whole turn on `tool_search` just to load it (bench 41:
798
- * ~6 s per fan-out). Promote it as soon as a spawn succeeds instead: the tool
799
- * list grows exactly as it would after that `tool_search`, one turn earlier,
800
- * and sessions that never spawn keep the smaller prefix.
801
- */
802
- promoteWaitAgentAfterSpawn() {
803
- const index = this.tools.findIndex((t) => t.name === "spawn_agent");
804
- const spawn = index >= 0 ? this.tools[index] : undefined;
805
- 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)
806
707
  return;
807
708
  this.tools[index] = {
808
- ...spawn,
709
+ ...tool,
809
710
  execute: async (args, context) => {
810
- const result = await spawn.execute(args, context);
811
- if (!this.tools.some((t) => t.name === "wait_agent")) {
812
- 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));
813
716
  }
814
717
  return result;
815
718
  },
816
719
  };
817
720
  }
818
- /** Append tools, replacing any same-named entry (cached stub → live tool). */
819
- replaceOrPushTools(tools) {
820
- for (const tool of tools) {
821
- const index = this.tools.findIndex((t) => t.name === tool.name);
822
- if (index >= 0)
823
- this.tools[index] = tool;
824
- else
825
- this.tools.push(tool);
826
- }
827
- }
828
- /** Swap already-promoted cached stubs for their live equivalents, in place. */
829
- replaceLivePromotedTools(tools) {
830
- for (const tool of tools) {
831
- const index = this.tools.findIndex((t) => t.name === tool.name);
832
- if (index >= 0)
833
- this.tools[index] = tool;
834
- }
835
- }
836
721
  /**
837
722
  * Publish cached tool definitions into the deferred catalog so `tool_search`
838
- * answers correctly on turn 1. A cached stub carries the real name, one-line
839
- * description and input schema; calling it waits for the live connection and
840
- * then dispatches against the real client, or returns a clear error when that
841
- * server ultimately failed. Live tools replace stubs on connect.
723
+ * answers correctly on turn 1 (see {@link McpToolRegistry.seedFromCache}).
842
724
  */
843
725
  async seedMcpCatalogFromCache(servers) {
844
726
  if (!this.opts.backgroundMcpConnect)
845
727
  return;
846
728
  if (this.opts.allowedTools || !this.settingsManager.get("deferredMcpTools"))
847
729
  return;
848
- let entries;
849
- try {
850
- entries = await this.mcpCatalogCache.entriesFor(servers);
851
- }
852
- catch {
853
- return;
854
- }
855
- const stubs = [];
856
- for (const [serverName, entry] of entries) {
857
- for (const cached of entry.tools) {
858
- if (this.liveMcpTools.has(cached.name))
859
- continue;
860
- this.cachedMcpToolServers.set(cached.name, serverName);
861
- stubs.push(this.buildCachedMcpTool(serverName, cached));
862
- }
863
- }
864
- if (stubs.length === 0)
865
- return;
866
- log("INFO", "mcp", "Seeded deferred tool catalog from cache", {
867
- tools: String(stubs.length),
868
- servers: String(entries.size),
869
- });
870
- this.addCachedMcpTools(stubs);
871
- }
872
- /** Catalog-only registration for cached stubs — never marks them live. */
873
- addCachedMcpTools(stubs) {
874
- this.mcpCatalog ??= new DeferredToolCatalog(this.contextLimits);
875
- this.mcpCatalog.add(stubs);
876
- this.ensureToolSearchTool();
877
- }
878
- buildCachedMcpTool(serverName, cached) {
879
- return {
880
- name: cached.name,
881
- description: cached.description,
882
- parameters: z.record(z.string(), z.unknown()),
883
- rawInputSchema: cached.rawInputSchema,
884
- execute: async (args, context) => {
885
- const live = this.liveMcpTools.get(cached.name);
886
- if (live)
887
- return live.execute(args, context);
888
- const outcome = (await this.mcpManager?.whenConnected(serverName)) ?? {
889
- ok: false,
890
- error: "MCP is disabled for this session",
891
- };
892
- if (!outcome.ok) {
893
- return (`MCP tool ${cached.name} is unavailable: server "${serverName}" did not connect ` +
894
- `(${outcome.error}). This tool was offered from a cached catalog. ` +
895
- `Use a different approach or ask the user to check their MCP configuration.`);
896
- }
897
- const connected = this.liveMcpTools.get(cached.name);
898
- if (!connected) {
899
- return (`MCP tool ${cached.name} no longer exists: server "${serverName}" connected but ` +
900
- `does not expose it. The cached catalog entry was stale.`);
901
- }
902
- return connected.execute(args, context);
903
- },
904
- };
730
+ await this.mcp.seedFromCache(servers);
905
731
  }
906
732
  /**
907
733
  * Resolve a `/name [args]` input to the prompt template it expands into, or
@@ -1090,34 +916,15 @@ export class AgentSession {
1090
916
  resetHookState(originalRequest) {
1091
917
  this.opts.completionReview?.begin(originalRequest);
1092
918
  this.lspManager?.clearPendingDiagnostics();
1093
- this.hookStats = { toolCalls: 0, toolFailures: 0, turns: 0 };
1094
- this.hookText = "";
1095
- this.hookConsecutiveFailures = 0;
1096
- this.hookRepeatedNoProgressCalls = 0;
1097
- this.hookProgressTracker.reset();
1098
- this.hookCycleDetector.reset();
1099
- this.hookCyclicPattern = null;
1100
- this.hookFileEditCounts.clear();
1101
- this.hookToolCalls.clear();
1102
- this.loopBreakInjected = 0;
919
+ this.loopMonitor.reset();
920
+ this.verificationTracker.resetRun();
1103
921
  this.regroundingInjected = false;
1104
- this.hookRecentCalls = [];
1105
- this.semanticLoop = {
1106
- checksUsed: 0,
1107
- lastCheckTurn: 0,
1108
- pending: false,
1109
- verdict: null,
1110
- injected: false,
1111
- };
1112
922
  this.runStartedAt = Date.now();
1113
923
  this.processGateInjected = 0;
1114
924
  this.planSubmissionNudges = 0;
1115
925
  this.verificationGate.beginRun();
1116
926
  const processes = new Set(this.processManager?.list().map((p) => p.id) ?? []);
1117
- for (const id of this.backgroundVerification.keys()) {
1118
- if (!processes.has(id))
1119
- this.backgroundVerification.delete(id);
1120
- }
927
+ this.verificationTracker.pruneBackground(processes);
1121
928
  this.compactionOccurred = false;
1122
929
  // Post-turn compaction may have landed between runs — adopt its armed
1123
930
  // signal so the re-grounding hook fires for this run exactly as it would
@@ -1141,160 +948,20 @@ export class AgentSession {
1141
948
  }
1142
949
  switch (event.type) {
1143
950
  case "text_delta":
1144
- this.hookText += event.text;
951
+ this.loopMonitor.recordText(event.text);
1145
952
  break;
1146
953
  case "tool_call_start": {
1147
- this.hookToolCalls.set(event.toolCallId, {
1148
- name: event.name,
1149
- args: event.args ?? {},
1150
- revision: this.verificationGate.revision,
1151
- });
1152
- const startClassification = event.name === "bash" && typeof event.args?.command === "string"
1153
- ? classifyVerificationCommand(event.args.command)
1154
- : null;
1155
- if (startClassification &&
1156
- typeof event.args?.command === "string" &&
1157
- (isVerificationCommand(event.args.command) ||
1158
- startClassification.accepted ||
1159
- // Must match the tool_call_end predicate: a snapshot-eligible
1160
- // command that never captured a "before" snapshot is misread at
1161
- // the end as an uncomparable workspace and re-arms the gate.
1162
- startClassification.snapshotEligible === true)) {
1163
- // A check that can rewrite files (--fix, build scripts, emitters)
1164
- // invalidates earlier in-flight evidence AND marks the run as
1165
- // touched. A check that is merely UNRECOGNIZED (`make test`, `deno
1166
- // test`) rewrites nothing we can point to: bumping the revision for
1167
- // it poisoned the gate on green output and re-armed the hook into
1168
- // every later question turn.
1169
- this.verificationGate.recordVerificationAttempt();
1170
- const classification = startClassification;
1171
- if (classification.snapshotEligible && event.args.persist !== true) {
1172
- const call = this.hookToolCalls.get(event.toolCallId);
1173
- call.sourceSnapshot = await captureVerificationSnapshot(this.opts.cwd, [
1174
- ...this.hookFileEditCounts.keys(),
1175
- ]);
1176
- if (call.sourceSnapshot === null)
1177
- this.verificationGate.requireFreshVerification(true);
1178
- }
1179
- else if ((classification.accepted && event.args.persist !== true) ||
1180
- (!classification.accepted && classification.mayMutate)) {
1181
- // Flag the workspace unknown only when tool_call_end can resolve
1182
- // it: a bounded check records pass/fail, a file-rewriting command
1183
- // bumps the revision. An unrecognized read-only check (`biome ci`)
1184
- // or a persistent-shell run records nothing at the end, so
1185
- // flagging it left verified work Unverified forever — and
1186
- // autopilot silently refused every later turn.
1187
- this.verificationGate.requireFreshVerification(!classification.accepted && classification.mayMutate);
1188
- }
954
+ if (await this.verificationTracker.recordToolCallStart(event)) {
1189
955
  await this.persistVerificationState();
1190
956
  }
1191
957
  break;
1192
958
  }
1193
959
  case "tool_call_end": {
1194
- const call = this.hookToolCalls.get(event.toolCallId);
960
+ const call = this.verificationTracker.getToolCall(event.toolCallId);
1195
961
  const name = call?.name ?? "";
1196
962
  const args = call?.args;
1197
- this.hookStats.toolCalls += 1;
1198
- if (event.isError)
1199
- this.hookStats.toolFailures += 1;
1200
- this.hookConsecutiveFailures = event.isError ? this.hookConsecutiveFailures + 1 : 0;
1201
- this.hookRepeatedNoProgressCalls = this.hookProgressTracker.record(name, args, event.result, event.isError);
1202
- this.hookCyclicPattern = this.hookCycleDetector.record(name, args, event.result, event.isError);
1203
- // Semantic-loop judge input: a bounded digest of WHAT was attempted and
1204
- // HOW it came out. Args/results are sliced AT RECORD TIME — a write with
1205
- // a 50 KiB payload or a bash dump must never inflate the ring, and the
1206
- // judge needs shapes, not payloads.
1207
- this.hookRecentCalls.push({
1208
- tool: name,
1209
- args: args === undefined ? "" : JSON.stringify(args).slice(0, 300),
1210
- ok: !event.isError,
1211
- result: event.result.slice(0, 400),
1212
- });
1213
- if (this.hookRecentCalls.length > MAX_SEMANTIC_LOOP_CALLS) {
1214
- this.hookRecentCalls.splice(0, this.hookRecentCalls.length - MAX_SEMANTIC_LOOP_CALLS);
1215
- }
1216
- // Only host-observed successful mutations and trustworthy check results
1217
- // affect approval. The model's text is never evidence.
1218
- let verificationChanged = false;
1219
- // In plan mode write/edit can only touch `.gg/plans/*.md`; a code-file
1220
- // write there was refused (refusals return text, not isError). Counting
1221
- // it as a change left "unverified" work that blocked plan approval.
1222
- if (!event.isError && args && !this.planModeRef.current) {
1223
- if (name === "edit" || name === "write") {
1224
- // Check-owning files (tsconfig.json, pytest.ini, vitest.config.ts …)
1225
- // are tracked even when they are not source code: editing one
1226
- // invalidates earlier check results. A multi-file edit records every file.
1227
- for (const filePath of editTargetPaths(args)) {
1228
- if (isCodeFilePath(filePath) || isCheckOwnFile(filePath)) {
1229
- this.verificationGate.recordMutation(filePath);
1230
- verificationChanged = true;
1231
- }
1232
- }
1233
- }
1234
- }
1235
- if (args && call && name === "bash") {
1236
- const command = typeof args.command === "string" ? args.command : "";
1237
- const classification = classifyVerificationCommand(command);
1238
- if (args.review === true && event.result === REVIEW_REJECTED_BEFORE_START) {
1239
- // The core tool rejected its arguments BEFORE spawning. Never mint
1240
- // a pass, or poison the failed-check ledger with an unexecuted check.
1241
- // A child's output cannot match: bash always prefixes it with Exit code.
1242
- this.verificationGate.recordRejectedCheck(command, REVIEW_REJECTED_BEFORE_START);
1243
- delete call.sourceSnapshot;
1244
- verificationChanged = true;
1245
- }
1246
- else if (classification.accepted || classification.snapshotEligible) {
1247
- // A foreground check that outlived the default budget was moved to
1248
- // the background, not failed: track it to its real exit the same way.
1249
- const autoBackgroundId = event.isError ? undefined : autoBackgroundedId(event.result);
1250
- if ((autoBackgroundId !== undefined || args.run_in_background === true) &&
1251
- !event.isError &&
1252
- args.persist !== true) {
1253
- const id = autoBackgroundId ?? /^ID:\s*(\S+)/m.exec(event.result)?.[1];
1254
- // No parseable ID means the check cannot be tracked to a real exit
1255
- // code — no evidence either way. Recording a FAILURE here made
1256
- // every later green run of a different spelling look owed.
1257
- if (id)
1258
- this.backgroundVerification.set(id, {
1259
- revision: call.revision,
1260
- command,
1261
- ...(classification.snapshotEligible
1262
- ? { sourceSnapshot: call.sourceSnapshot ?? null }
1263
- : {}),
1264
- });
1265
- else if (classification.snapshotEligible)
1266
- this.verificationGate.requireFreshVerification(true);
1267
- }
1268
- else if (args.persist === true) {
1269
- // Persistent-shell checks are not bounded evidence (steering can
1270
- // interleave): neither a pass nor a failure. A recorded failure
1271
- // here blocked approval for sessions that prefer the shell.
1272
- }
1273
- else if (classification.snapshotEligible) {
1274
- await this.finishSnapshotVerification({ command, revision: call.revision, sourceSnapshot: call.sourceSnapshot ?? null }, !event.isError && /^Exit code:\s*0(?:\s|$)/i.test(event.result.trim()));
1275
- verificationChanged = true;
1276
- }
1277
- else {
1278
- if (!event.isError && /^Exit code:\s*0(?:\s|$)/i.test(event.result.trim())) {
1279
- this.verificationGate.recordVerification(call.revision, command);
1280
- }
1281
- else {
1282
- this.verificationGate.recordFailedVerification(command, call.revision);
1283
- }
1284
- verificationChanged = true;
1285
- }
1286
- delete call.sourceSnapshot;
1287
- }
1288
- else if (classification.candidate) {
1289
- // Green but untrusted: remember WHY so the demand can tell the
1290
- // agent which command shape actually clears the gate.
1291
- this.verificationGate.recordRejectedCheck(command, classification.reason);
1292
- }
1293
- }
1294
- if (!event.isError && args && name === "task_output" && typeof args.id === "string") {
1295
- verificationChanged =
1296
- (await this.recordFinishedBackgroundVerification(args.id)) || verificationChanged;
1297
- }
963
+ this.loopMonitor.recordToolEnd(name, args, event.result, event.isError);
964
+ const verificationChanged = await this.verificationTracker.recordToolCallEnd(event, call, this.planModeRef.current);
1298
965
  if (verificationChanged)
1299
966
  await this.persistVerificationState();
1300
967
  // Tool results are what push the run over the review gate, and they all
@@ -1304,7 +971,7 @@ export class AgentSession {
1304
971
  break;
1305
972
  }
1306
973
  case "turn_end":
1307
- this.hookStats.turns = event.turn;
974
+ this.loopMonitor.recordTurn(event.turn);
1308
975
  this.refreshHookArming();
1309
976
  for (let index = this.messages.length - 1; index >= 0; index--) {
1310
977
  const anchor = this.messages[index];
@@ -1481,51 +1148,26 @@ export class AgentSession {
1481
1148
  // Deterministic stuck verdict, computed once and shared: the semantic
1482
1149
  // judge must not spend tokens on a burst the deterministic breaker is
1483
1150
  // about to correct itself.
1484
- const deterministicDecision = evaluateLoopBreak({
1485
- consecutiveFailures: this.hookConsecutiveFailures,
1486
- repeatedNoProgressCalls: this.hookRepeatedNoProgressCalls,
1487
- textRepetitionDetected: detectTextRepetition(this.hookText),
1488
- ...(this.hookCyclicPattern ? { cyclicPattern: this.hookCyclicPattern } : {}),
1489
- });
1151
+ const deterministicDecision = this.loopMonitor.evaluateLoopBreak();
1490
1152
  this.maybeStartSemanticLoopCheck(deterministicDecision.shouldBreak);
1491
1153
  // Two-stage loop-breaker: stage 1 nudges; a FRESH detection after that
1492
1154
  // injects the harsher final stop-and-report prompt. Signals reset after
1493
1155
  // each injection so stage 2 only fires on new evidence.
1494
- if (this.loopBreakInjected < 2) {
1495
- const decision = deterministicDecision;
1496
- if (decision.shouldBreak) {
1497
- const stage = this.loopBreakInjected === 0 ? 1 : 2;
1498
- this.loopBreakInjected = stage;
1499
- this.hookProgressTracker.reset();
1500
- this.hookCycleDetector.reset();
1501
- this.hookCyclicPattern = null;
1502
- this.hookConsecutiveFailures = 0;
1503
- this.hookRepeatedNoProgressCalls = 0;
1504
- // The deterministic breaker owns this burst — a semantic verdict from
1505
- // the same burst must not double-correct on the next poll.
1506
- this.semanticLoop.verdict = null;
1507
- // Clear the text buffer too — otherwise a stage-1 text-repetition
1508
- // trigger still sees the same repeated tail on the next check and
1509
- // escalates to stage 2 on stale evidence.
1510
- this.hookText = "";
1511
- log("INFO", "loop-break", "Injecting loop-break nudge", {
1512
- stage: String(stage),
1513
- reasons: decision.reasons.join(", "),
1514
- });
1515
- this.eventBus.emit("hook", { kind: "loop_break" });
1516
- return [buildLoopBreakMessage(decision.reasons, stage === 2)];
1517
- }
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)];
1518
1164
  }
1519
1165
  // Semantic loop-break: an LLM verdict (started by maybeStartSemanticLoopCheck
1520
1166
  // on a suspicious-but-syntactically-quiet burst) is consumed here, exactly
1521
1167
  // once, with the deterministic breaker getting priority above. Fail-open:
1522
1168
  // no verdict or no-loop verdict injects nothing.
1523
- if (this.semanticLoop.verdict && !this.semanticLoop.injected) {
1524
- const verdict = this.semanticLoop.verdict;
1525
- this.semanticLoop.injected = true;
1526
- this.semanticLoop.verdict = null;
1527
- // The judged burst has been addressed; a fresh burst must re-accumulate.
1528
- this.hookConsecutiveFailures = 0;
1169
+ const verdict = this.loopMonitor.takeSemanticVerdict();
1170
+ if (verdict) {
1529
1171
  log("INFO", "loop-break", "Injecting semantic loop-break steering", {
1530
1172
  reason: verdict.reason,
1531
1173
  });
@@ -1545,34 +1187,26 @@ export class AgentSession {
1545
1187
  * Fire-and-forget: the call runs while the next turn streams, and a finished
1546
1188
  * verdict is consumed by the NEXT steering poll — never blocking a turn. */
1547
1189
  maybeStartSemanticLoopCheck(deterministicBreak) {
1548
- if (!shouldRunSemanticLoopCheck({
1549
- consecutiveFailures: this.hookConsecutiveFailures,
1550
- totalFailures: this.hookStats.toolFailures,
1551
- turns: this.hookStats.turns,
1552
- lastCheckTurn: this.semanticLoop.lastCheckTurn,
1553
- checksUsed: this.semanticLoop.checksUsed,
1554
- checkPending: this.semanticLoop.pending,
1555
- deterministicBreak,
1556
- })) {
1190
+ const monitor = this.loopMonitor;
1191
+ if (!monitor.shouldRunSemanticCheck(deterministicBreak)) {
1557
1192
  return;
1558
1193
  }
1559
- // resetHookState replaces this object on every prompt. A late judge must
1194
+ // resetHookState replaces the run state on every prompt. A late judge must
1560
1195
  // not publish a verdict or consume the next run's budget/cooldown.
1561
- const runState = this.semanticLoop;
1562
- runState.pending = true;
1196
+ const runState = monitor.beginSemanticCheck();
1563
1197
  log("INFO", "loop-break", "Starting semantic loop judge", {
1564
- turn: String(this.hookStats.turns),
1565
- consecutiveFailures: String(this.hookConsecutiveFailures),
1566
- recentCalls: String(this.hookRecentCalls.length),
1198
+ turn: String(monitor.stats.turns),
1199
+ consecutiveFailures: String(monitor.consecutiveFailures),
1200
+ recentCalls: String(monitor.recentCalls.length),
1567
1201
  });
1568
1202
  void (async () => {
1569
1203
  try {
1570
- const prompt = buildSemanticLoopJudgePrompt(this.hookRecentCalls, this.originalRequest);
1204
+ const prompt = buildSemanticLoopJudgePrompt(monitor.recentCalls, this.originalRequest);
1571
1205
  const raw = await (this.opts.semanticLoopJudge?.(prompt) ??
1572
1206
  this.callSemanticLoopJudge(prompt));
1573
1207
  const verdict = parseSemanticLoopVerdict(raw);
1574
- if (this.semanticLoop === runState && verdict?.loop)
1575
- runState.verdict = verdict;
1208
+ if (verdict?.loop)
1209
+ monitor.publishSemanticVerdict(runState, verdict);
1576
1210
  }
1577
1211
  catch (error) {
1578
1212
  // Fail open: judge errors never stop a run. Budget and cooldown are
@@ -1582,11 +1216,7 @@ export class AgentSession {
1582
1216
  });
1583
1217
  }
1584
1218
  finally {
1585
- if (this.semanticLoop === runState) {
1586
- runState.pending = false;
1587
- runState.checksUsed += 1;
1588
- runState.lastCheckTurn = this.hookStats.turns;
1589
- }
1219
+ monitor.settleSemanticCheck(runState);
1590
1220
  }
1591
1221
  })();
1592
1222
  }
@@ -1682,23 +1312,7 @@ export class AgentSession {
1682
1312
  * with — so reuse the same stuck signals that drive the loop-breaker.
1683
1313
  */
1684
1314
  shouldExtendTurnBudget(ctx) {
1685
- const refusals = [];
1686
- const stuck = evaluateLoopBreak({
1687
- consecutiveFailures: this.hookConsecutiveFailures,
1688
- repeatedNoProgressCalls: this.hookRepeatedNoProgressCalls,
1689
- textRepetitionDetected: detectTextRepetition(this.hookText),
1690
- ...(this.hookCyclicPattern ? { cyclicPattern: this.hookCyclicPattern } : {}),
1691
- });
1692
- if (stuck.shouldBreak)
1693
- refusals.push(...stuck.reasons);
1694
- // Stage 2 means the loop-breaker already detected spinning twice and told
1695
- // the agent to stop and report. Do not overrule that with more turns.
1696
- if (this.loopBreakInjected >= 2)
1697
- refusals.push("loop-breaker already escalated");
1698
- const { toolCalls, toolFailures } = this.hookStats;
1699
- if (toolCalls > 0 && toolFailures / toolCalls > TURN_EXTENSION_MAX_FAILURE_RATIO) {
1700
- refusals.push(`${toolFailures}/${toolCalls} tool calls failed`);
1701
- }
1315
+ const refusals = this.loopMonitor.turnExtensionRefusals();
1702
1316
  const granted = refusals.length === 0;
1703
1317
  log("INFO", "turn-budget", granted ? "Extending turn budget" : "Refusing turn-budget extension", {
1704
1318
  turn: String(ctx.turn),
@@ -1734,11 +1348,7 @@ export class AgentSession {
1734
1348
  async getHookFollowUpMessages() {
1735
1349
  // Exit notifications and task_output refer to the same host process record.
1736
1350
  // Do not force an extra polling tool/turn merely to acknowledge a known exit.
1737
- let backgroundChanged = false;
1738
- for (const id of this.backgroundVerification.keys()) {
1739
- backgroundChanged =
1740
- (await this.recordFinishedBackgroundVerification(id)) || backgroundChanged;
1741
- }
1351
+ const backgroundChanged = await this.verificationTracker.settleFinishedBackground();
1742
1352
  if (backgroundChanged)
1743
1353
  await this.persistVerificationState();
1744
1354
  // Edits return immediately; only the completion boundary waits for remaining
@@ -2015,6 +1625,11 @@ export class AgentSession {
2015
1625
  maxTurnToolResultChars: resolveSessionTurnToolResultCharLimit(this.model, this.provider, accountId),
2016
1626
  // Warn when web/MCP output contains instruction-like text (see injection-detect.ts).
2017
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) }),
2018
1633
  // Self-correction hooks (same as the TUI): loop-break + re-grounding are
2019
1634
  // polled mid-loop; the ideal review is polled when the agent would stop.
2020
1635
  getSteeringMessages: () => this.getHookSteeringMessages(),
@@ -2336,11 +1951,12 @@ export class AgentSession {
2336
1951
  // there avoids tearing down a live stdio child and
2337
1952
  // gambling on a `npx` re-spawn that could fail and drop the tools.
2338
1953
  const glmInvolved = this.provider === "glm" || prevProvider === "glm";
2339
- if (this.mcpManager && glmInvolved) {
1954
+ const mcpManager = this.mcp.manager;
1955
+ if (mcpManager && glmInvolved) {
2340
1956
  // Remove old MCP tools
2341
1957
  this.tools = this.tools.filter((t) => !t.name.startsWith("mcp__"));
2342
1958
  // Disconnect old MCP servers
2343
- await this.mcpManager.dispose();
1959
+ await mcpManager.dispose();
2344
1960
  // Connect new MCP servers for the new provider
2345
1961
  try {
2346
1962
  let apiKey;
@@ -2357,13 +1973,11 @@ export class AgentSession {
2357
1973
  const servers = await getAllMcpServers(this.provider, apiKey, this.cwd, {
2358
1974
  allowProjectScope: this.settingsManager.isProjectTrusted(this.cwd),
2359
1975
  });
2360
- const mcpTools = await this.mcpManager.connectAll(servers);
1976
+ const mcpTools = await mcpManager.connectAll(servers);
2361
1977
  // Drop stale MCP tools from both the live set and deferred catalog before
2362
1978
  // re-adding. Some tools may already have been promoted out of the catalog.
2363
1979
  this.tools = this.tools.filter((t) => !t.name.startsWith("mcp__"));
2364
- this.mcpCatalog?.removeWhere((name) => name.startsWith("mcp__"));
2365
- this.liveMcpTools.clear();
2366
- this.cachedMcpToolServers.clear();
1980
+ this.mcp.clearMcpTools();
2367
1981
  this.addMcpTools(mcpTools);
2368
1982
  }
2369
1983
  catch (err) {
@@ -2406,10 +2020,7 @@ export class AgentSession {
2406
2020
  this.checkpointGeneration = loaded.header.generation ?? 0;
2407
2021
  this.currentLeafId = loaded.header.leafId;
2408
2022
  this.setSessionPath(loaded.path);
2409
- this.kenTurns = this.sessionManager.getKenTurns(loaded.entries, loaded.header.leafId);
2410
- this.autopilotMarkers = this.sessionManager.getAutopilotMarkers(loaded.entries, loaded.header.leafId);
2411
- this.appMarkers = this.sessionManager.getAppMarkers(loaded.entries, loaded.header.leafId);
2412
- this.turnMetrics = this.sessionManager.getTurnMetrics(loaded.entries);
2023
+ this.markers.restore(loaded.entries, loaded.header.leafId);
2413
2024
  this.lastPersistedIndex = this.messages.length;
2414
2025
  this.providerContext = null;
2415
2026
  await this.subAgentManager?.rebindParentSession(this.sessionId);
@@ -2449,10 +2060,7 @@ export class AgentSession {
2449
2060
  await this.persistMessage(message);
2450
2061
  }
2451
2062
  this.lastPersistedIndex = this.messages.length;
2452
- await this.rePersistTurnMetrics();
2453
- await this.rePersistKenTurns();
2454
- await this.rePersistAutopilotMarkers();
2455
- await this.rePersistAppMarkers();
2063
+ await this.markers.rePersistAll();
2456
2064
  await this.persistVerificationState();
2457
2065
  await this.persistAppMarker("compaction", {
2458
2066
  originalCount: result.originalCount,
@@ -2821,7 +2429,7 @@ export class AgentSession {
2821
2429
  // explicit new sessions reset the conversation identity.
2822
2430
  if (!preserveConversation) {
2823
2431
  this.verificationGate.reset();
2824
- this.backgroundVerification.clear();
2432
+ this.verificationTracker.clearBackground();
2825
2433
  this.conversationId = "";
2826
2434
  this.checkpointGeneration = 0;
2827
2435
  this.sessionPreview = "";
@@ -2833,10 +2441,7 @@ export class AgentSession {
2833
2441
  // turns / autopilot verdicts / app markers linger in memory, show up in the
2834
2442
  // new session's /history, and get re-persisted into the new file by the
2835
2443
  // next compaction — the cross-session duplicate-marker propagation bug.
2836
- this.kenTurns = [];
2837
- this.autopilotMarkers = [];
2838
- this.appMarkers = [];
2839
- this.turnMetrics = [];
2444
+ this.markers.clear();
2840
2445
  const basePrompt = await this.buildBasePrompt(false, undefined);
2841
2446
  this.baseSystemPrompt = basePrompt;
2842
2447
  this.messages = [{ role: "system", content: this.withSystemPromptTail(basePrompt) }];
@@ -2958,9 +2563,7 @@ export class AgentSession {
2958
2563
  else {
2959
2564
  used = calculateActiveContextTokens(this.messages);
2960
2565
  }
2961
- const costUsd = this.turnMetrics.length > 0 && this.turnMetrics.every((m) => m.cost.status === "known")
2962
- ? this.turnMetrics.reduce((sum, m) => sum + (m.cost.status === "known" ? m.cost.usd : 0), 0)
2963
- : undefined;
2566
+ const costUsd = this.markers.knownCostUsd();
2964
2567
  return costUsd === undefined ? { used, size } : { used, size, costUsd };
2965
2568
  }
2966
2569
  /**
@@ -3084,6 +2687,11 @@ export class AgentSession {
3084
2687
  */
3085
2688
  async setPlanMode(active) {
3086
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
+ }
3087
2695
  // Entering plan mode discards any prior approved-plan contract (a new plan
3088
2696
  // is about to be drafted); exiting keeps it (set explicitly via accept).
3089
2697
  if (active)
@@ -3287,7 +2895,10 @@ export class AgentSession {
3287
2895
  orchestrationPolicyTail() {
3288
2896
  if (this.opts.orchestrationPrompt === false)
3289
2897
  return "";
3290
- 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();
3291
2902
  }
3292
2903
  /**
3293
2904
  * Re-render the uncached tail of messages[0] in place. The cached prefix is
@@ -3309,12 +2920,7 @@ export class AgentSession {
3309
2920
  return this.messages;
3310
2921
  }
3311
2922
  getTurnMetrics() {
3312
- return this.turnMetrics.map((metric) => ({
3313
- ...metric,
3314
- usage: { ...metric.usage },
3315
- timing: { ...metric.timing },
3316
- cost: { ...metric.cost },
3317
- }));
2923
+ return this.markers.getTurnMetrics();
3318
2924
  }
3319
2925
  async persistTurnMetric(event) {
3320
2926
  if (event.stopReason === "error")
@@ -3341,31 +2947,23 @@ export class AgentSession {
3341
2947
  reason: "No authoritative effective-dated provider pricing is available",
3342
2948
  },
3343
2949
  };
3344
- this.turnMetrics.push(payload);
2950
+ this.markers.addTurnMetric(payload);
3345
2951
  // Internal diagnostics piggyback on the authoritative per-turn metric —
3346
2952
  // one source of truth, and the record flushes to disk every turn.
3347
2953
  this.diagnosticsRecorder?.recordTurnMetric(payload);
3348
- if (this.sessionPath)
3349
- await this.sessionManager.appendTurnMetric(this.sessionPath, payload);
3350
- }
3351
- async rePersistTurnMetrics() {
3352
- if (!this.sessionPath)
3353
- return;
3354
- for (const metric of this.turnMetrics) {
3355
- await this.sessionManager.appendTurnMetric(this.sessionPath, metric);
3356
- }
2954
+ await this.markers.persistTurnMetric(payload);
3357
2955
  }
3358
2956
  /** Ken Kai (mentor) turns recorded against this session, in record order. Used
3359
2957
  * by the host to interleave Ken's advisory exchanges back into the transcript
3360
2958
  * on resume. Never part of the LLM message history. */
3361
2959
  getKenTurns() {
3362
- return this.kenTurns;
2960
+ return this.markers.getKenTurns();
3363
2961
  }
3364
2962
  /** Autopilot verdict markers recorded against this session, in record order.
3365
2963
  * Used by the host to interleave the auto-review loop's markers back into
3366
2964
  * the transcript on resume, mirroring `getKenTurns`. */
3367
2965
  getAutopilotMarkers() {
3368
- return this.autopilotMarkers;
2966
+ return this.markers.getAutopilotMarkers();
3369
2967
  }
3370
2968
  /** Non-system messages that are actually on disk. Transcript markers anchor
3371
2969
  * against this (not the in-memory list, which can run ahead after a failed
@@ -3380,15 +2978,7 @@ export class AgentSession {
3380
2978
  * file, so the new file carries positions that match its own transcript.
3381
2979
  */
3382
2980
  remapMarkerAnchors(remap) {
3383
- if (!remap)
3384
- return;
3385
- const move = (payload) => ({
3386
- ...payload,
3387
- afterMessageCount: remapAnchorForCompaction(payload.afterMessageCount, remap),
3388
- });
3389
- this.kenTurns = this.kenTurns.map(move);
3390
- this.autopilotMarkers = this.autopilotMarkers.map(move);
3391
- this.appMarkers = this.appMarkers.map(move);
2981
+ this.markers.remapAnchors(remap);
3392
2982
  }
3393
2983
  /**
3394
2984
  * Record one Ken Kai (mentor agent) turn against this build session: the
@@ -3401,39 +2991,7 @@ export class AgentSession {
3401
2991
  * appendEntry's own handling.
3402
2992
  */
3403
2993
  async persistKenTurn(question, reply) {
3404
- const afterMessageCount = this.persistedTranscriptCount();
3405
- const payload = { version: 1, question, reply, afterMessageCount };
3406
- this.kenTurns.push(payload);
3407
- if (!this.sessionPath)
3408
- return;
3409
- const entry = {
3410
- type: "custom",
3411
- kind: KEN_TURN_CUSTOM_KIND,
3412
- id: crypto.randomUUID(),
3413
- parentId: null,
3414
- timestamp: new Date().toISOString(),
3415
- data: payload,
3416
- };
3417
- await this.sessionManager.appendEntry(this.sessionPath, entry);
3418
- }
3419
- /** Re-append the in-memory Ken turns to the current session file. Called after
3420
- * a continuation/compaction file is created so Ken's advisory history isn't
3421
- * lost when the session is rewritten (those rewrites only re-persist
3422
- * messages). Each turn keeps its original `afterMessageCount` anchor. */
3423
- async rePersistKenTurns() {
3424
- if (!this.sessionPath)
3425
- return;
3426
- for (const payload of this.kenTurns) {
3427
- const entry = {
3428
- type: "custom",
3429
- kind: KEN_TURN_CUSTOM_KIND,
3430
- id: crypto.randomUUID(),
3431
- parentId: null,
3432
- timestamp: new Date().toISOString(),
3433
- data: stripRecordedPosition(payload),
3434
- };
3435
- await this.sessionManager.appendEntry(this.sessionPath, entry);
3436
- }
2994
+ await this.markers.recordKenTurn(question, reply, this.persistedTranscriptCount());
3437
2995
  }
3438
2996
  /**
3439
2997
  * Record one autopilot verdict marker (prompted / done / human / capped)
@@ -3443,23 +3001,6 @@ export class AgentSession {
3443
3001
  * instead of dropping the marker or falling back to a raw verdict string.
3444
3002
  * No-op persistence for transient sessions (kept in memory only).
3445
3003
  */
3446
- async recordFinishedBackgroundVerification(id) {
3447
- const started = this.backgroundVerification.get(id);
3448
- const proc = this.processManager?.list().find((entry) => entry.id === id);
3449
- if (!started || !proc || proc.exitCode === null)
3450
- return false;
3451
- if (started.sourceSnapshot !== undefined) {
3452
- await this.finishSnapshotVerification(started, proc.exitCode === 0);
3453
- }
3454
- else if (proc.exitCode === 0) {
3455
- this.verificationGate.recordVerification(started.revision, started.command);
3456
- }
3457
- else {
3458
- this.verificationGate.recordFailedVerification(started.command, started.revision);
3459
- }
3460
- this.backgroundVerification.delete(id);
3461
- return true;
3462
- }
3463
3004
  getVerificationEvidence() {
3464
3005
  return this.verificationGate.evidence();
3465
3006
  }
@@ -3471,39 +3012,8 @@ export class AgentSession {
3471
3012
  evidence: this.verificationGate.evidence("run"),
3472
3013
  };
3473
3014
  }
3474
- async finishSnapshotVerification(check, passed) {
3475
- const after = check.sourceSnapshot
3476
- ? await captureVerificationSnapshot(this.opts.cwd, [...this.hookFileEditCounts.keys()])
3477
- : null;
3478
- if (after === null || after !== check.sourceSnapshot) {
3479
- this.verificationGate.requireFreshVerification(true);
3480
- this.verificationGate.recordRejectedCheck(check.command, after === null
3481
- ? "Workspace inputs could not be compared; run a read-only check after the command"
3482
- : "Command changed workspace inputs; run checks against the changed source");
3483
- if (!passed)
3484
- this.verificationGate.recordFailedVerification(check.command);
3485
- }
3486
- else if (passed) {
3487
- const classification = classifyVerificationCommand(check.command);
3488
- if (classification.snapshotPreserveOnly) {
3489
- this.verificationGate.recordRejectedCheck(check.command, classification.reason);
3490
- }
3491
- else {
3492
- this.verificationGate.recordVerification(check.revision, check.command);
3493
- }
3494
- }
3495
- else {
3496
- this.verificationGate.recordFailedVerification(check.command, check.revision);
3497
- }
3498
- }
3499
3015
  getVerificationProblem() {
3500
- if ([...this.hookToolCalls.values()].some((call) => call.sourceSnapshot !== undefined)) {
3501
- return "Unverified: a build is still running or its workspace comparison is pending.";
3502
- }
3503
- return (this.verificationGate.verificationProblem() ??
3504
- (this.backgroundVerification.size > 0
3505
- ? "Unverified: a background check is still running or its result has not been confirmed."
3506
- : null));
3016
+ return this.verificationTracker.verificationProblem();
3507
3017
  }
3508
3018
  async persistCompletionReviewState() {
3509
3019
  if (!this.sessionPath || !this.opts.completionReview)
@@ -3536,50 +3046,13 @@ export class AgentSession {
3536
3046
  await this.sessionManager.appendEntry(this.sessionPath, entry);
3537
3047
  }
3538
3048
  async persistAutopilotMarker(phase, extra) {
3539
- const afterMessageCount = this.persistedTranscriptCount();
3540
- const payload = {
3541
- version: 1,
3542
- phase,
3543
- afterMessageCount,
3544
- ...(extra?.reason !== undefined ? { reason: extra.reason } : {}),
3545
- ...(extra?.body !== undefined ? { body: extra.body } : {}),
3546
- };
3547
- this.autopilotMarkers.push(payload);
3548
- if (!this.sessionPath)
3549
- return;
3550
- const entry = {
3551
- type: "custom",
3552
- kind: AUTOPILOT_MARKER_CUSTOM_KIND,
3553
- id: crypto.randomUUID(),
3554
- parentId: null,
3555
- timestamp: new Date().toISOString(),
3556
- data: payload,
3557
- };
3558
- await this.sessionManager.appendEntry(this.sessionPath, entry);
3559
- }
3560
- /** Re-append the in-memory autopilot markers to the current session file.
3561
- * Mirrors `rePersistKenTurns` — called after a continuation/compaction file
3562
- * is created so the auto-review history survives the rewrite. */
3563
- async rePersistAutopilotMarkers() {
3564
- if (!this.sessionPath)
3565
- return;
3566
- for (const payload of this.autopilotMarkers) {
3567
- const entry = {
3568
- type: "custom",
3569
- kind: AUTOPILOT_MARKER_CUSTOM_KIND,
3570
- id: crypto.randomUUID(),
3571
- parentId: null,
3572
- timestamp: new Date().toISOString(),
3573
- data: stripRecordedPosition(payload),
3574
- };
3575
- await this.sessionManager.appendEntry(this.sessionPath, entry);
3576
- }
3049
+ await this.markers.recordAutopilotMarker(phase, extra, this.persistedTranscriptCount());
3577
3050
  }
3578
3051
  /** App transcript markers recorded against this session, in record order.
3579
3052
  * Used by the host to interleave display-only rows (plan banner, task
3580
3053
  * header, errors, user-bubble hints) back into the transcript on resume. */
3581
3054
  getAppMarkers() {
3582
- return this.appMarkers;
3055
+ return this.markers.getAppMarkers();
3583
3056
  }
3584
3057
  /**
3585
3058
  * Record one app transcript marker (display-only row) against this session.
@@ -3591,20 +3064,7 @@ export class AgentSession {
3591
3064
  * No-op persistence for transient sessions.
3592
3065
  */
3593
3066
  async persistAppMarker(kind, data, anchorOffset = 0) {
3594
- const afterMessageCount = this.persistedTranscriptCount() + anchorOffset;
3595
- const payload = { version: 1, kind, afterMessageCount, data };
3596
- this.appMarkers.push(payload);
3597
- if (!this.sessionPath)
3598
- return;
3599
- const entry = {
3600
- type: "custom",
3601
- kind: APP_MARKER_CUSTOM_KIND,
3602
- id: crypto.randomUUID(),
3603
- parentId: null,
3604
- timestamp: new Date().toISOString(),
3605
- data: payload,
3606
- };
3607
- await this.sessionManager.appendEntry(this.sessionPath, entry);
3067
+ await this.markers.recordAppMarker(kind, data, this.persistedTranscriptCount() + anchorOffset);
3608
3068
  }
3609
3069
  /**
3610
3070
  * Open the run journal for one `RunLifecycle` generation.
@@ -3633,24 +3093,6 @@ export class AgentSession {
3633
3093
  outcome,
3634
3094
  });
3635
3095
  }
3636
- /** Re-append the in-memory app markers to the current session file. Mirrors
3637
- * `rePersistKenTurns` — called after a continuation/compaction file is
3638
- * created so display-only rows survive the rewrite. */
3639
- async rePersistAppMarkers() {
3640
- if (!this.sessionPath)
3641
- return;
3642
- for (const payload of this.appMarkers) {
3643
- const entry = {
3644
- type: "custom",
3645
- kind: APP_MARKER_CUSTOM_KIND,
3646
- id: crypto.randomUUID(),
3647
- parentId: null,
3648
- timestamp: new Date().toISOString(),
3649
- data: stripRecordedPosition(payload),
3650
- };
3651
- await this.sessionManager.appendEntry(this.sessionPath, entry);
3652
- }
3653
- }
3654
3096
  /**
3655
3097
  * Rewrite a draft prompt into a tighter, terminology-correct version using
3656
3098
  * the ACTIVE provider/model. A stateless one-off LLM call (no agent loop, no
@@ -3892,7 +3334,7 @@ export class AgentSession {
3892
3334
  this.stopBackgroundProcesses();
3893
3335
  this.lspManager?.shutdownAll();
3894
3336
  this.debugManager?.shutdown();
3895
- await Promise.all([this.subAgentManager?.shutdownAll(), this.mcpManager?.dispose()]);
3337
+ await Promise.all([this.subAgentManager?.shutdownAll(), this.mcp.manager?.dispose()]);
3896
3338
  await this.extensionLoader.deactivateAll();
3897
3339
  this.setSessionPath("");
3898
3340
  this.eventBus.removeAllListeners();
@@ -3937,7 +3379,7 @@ export class AgentSession {
3937
3379
  .reverse()
3938
3380
  .find((entry) => entry.type === "custom" && entry.kind === COMPLETION_REVIEW_STATE_KIND);
3939
3381
  this.opts.completionReview?.restore(savedCompletionReview?.type === "custom" ? savedCompletionReview.data : null);
3940
- this.backgroundVerification.clear();
3382
+ this.verificationTracker.clearBackground();
3941
3383
  const savedVerification = [...loaded.entries]
3942
3384
  .reverse()
3943
3385
  .find((entry) => entry.type === "custom" && entry.kind === VERIFICATION_STATE_KIND);
@@ -3969,12 +3411,8 @@ export class AgentSession {
3969
3411
  // The leaf is passed so each marker also carries its FILE-order position,
3970
3412
  // the fallback used when a legacy anchor is out of range (see
3971
3413
  // RecordedPosition).
3972
- this.kenTurns = this.sessionManager.getKenTurns(loaded.entries, loaded.header.leafId);
3973
- // Restore autopilot verdict markers the same way (not on the message DAG).
3974
- this.autopilotMarkers = this.sessionManager.getAutopilotMarkers(loaded.entries, loaded.header.leafId);
3975
- // Restore app transcript markers (plan banner / task header / errors / hints).
3976
- this.appMarkers = this.sessionManager.getAppMarkers(loaded.entries, loaded.header.leafId);
3977
- 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);
3978
3416
  // A run that opened the journal and never closed it died mid-flight. Read
3979
3417
  // it here, before anything rewrites the file, and report it once the
3980
3418
  // transcript is in place.
@@ -4174,13 +3612,4 @@ export class AgentSession {
4174
3612
  }
4175
3613
  }
4176
3614
  }
4177
- /** Expand a leading `~` so `/import ~/.codex/...` works from any shell. */
4178
- function resolveHomePath(filePath) {
4179
- if (filePath === "~")
4180
- return os.homedir();
4181
- if (filePath.startsWith("~/") || filePath.startsWith("~\\")) {
4182
- return path.join(os.homedir(), filePath.slice(2));
4183
- }
4184
- return filePath;
4185
- }
4186
3615
  //# sourceMappingURL=agent-session.js.map