@prestyj/cli 5.6.0 → 5.8.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 (246) hide show
  1. package/README.md +2 -2
  2. package/dist/app-sidecar.js +369 -92
  3. package/dist/app-sidecar.js.map +1 -1
  4. package/dist/cli/auth.d.ts.map +1 -1
  5. package/dist/cli/auth.js +5 -2
  6. package/dist/cli/auth.js.map +1 -1
  7. package/dist/cli/shared.d.ts.map +1 -1
  8. package/dist/cli/shared.js +2 -0
  9. package/dist/cli/shared.js.map +1 -1
  10. package/dist/cli.d.ts.map +1 -1
  11. package/dist/cli.js +33 -3
  12. package/dist/cli.js.map +1 -1
  13. package/dist/config.d.ts.map +1 -1
  14. package/dist/config.js +1 -0
  15. package/dist/config.js.map +1 -1
  16. package/dist/config.test.js +7 -0
  17. package/dist/config.test.js.map +1 -1
  18. package/dist/core/agent-session-compaction.test.js +521 -10
  19. package/dist/core/agent-session-compaction.test.js.map +1 -1
  20. package/dist/core/agent-session-memory-tail.test.js +1 -1
  21. package/dist/core/agent-session-memory-tail.test.js.map +1 -1
  22. package/dist/core/agent-session-queue.test.js +1 -1
  23. package/dist/core/agent-session-queue.test.js.map +1 -1
  24. package/dist/core/agent-session-review-coverage.test.d.ts +2 -0
  25. package/dist/core/agent-session-review-coverage.test.d.ts.map +1 -0
  26. package/dist/core/agent-session-review-coverage.test.js +83 -0
  27. package/dist/core/agent-session-review-coverage.test.js.map +1 -0
  28. package/dist/core/agent-session-tool-result-policy.test.d.ts +2 -0
  29. package/dist/core/agent-session-tool-result-policy.test.d.ts.map +1 -0
  30. package/dist/core/agent-session-tool-result-policy.test.js +25 -0
  31. package/dist/core/agent-session-tool-result-policy.test.js.map +1 -0
  32. package/dist/core/agent-session.d.ts +51 -12
  33. package/dist/core/agent-session.d.ts.map +1 -1
  34. package/dist/core/agent-session.js +415 -106
  35. package/dist/core/agent-session.js.map +1 -1
  36. package/dist/core/auth-providers.d.ts.map +1 -1
  37. package/dist/core/auth-providers.js +15 -8
  38. package/dist/core/auth-providers.js.map +1 -1
  39. package/dist/core/compaction/active-context.d.ts +17 -0
  40. package/dist/core/compaction/active-context.d.ts.map +1 -0
  41. package/dist/core/compaction/active-context.js +20 -0
  42. package/dist/core/compaction/active-context.js.map +1 -0
  43. package/dist/core/compaction/active-context.test.d.ts +2 -0
  44. package/dist/core/compaction/active-context.test.d.ts.map +1 -0
  45. package/dist/core/compaction/active-context.test.js +36 -0
  46. package/dist/core/compaction/active-context.test.js.map +1 -0
  47. package/dist/core/compaction/compactor.d.ts +19 -18
  48. package/dist/core/compaction/compactor.d.ts.map +1 -1
  49. package/dist/core/compaction/compactor.js +64 -42
  50. package/dist/core/compaction/compactor.js.map +1 -1
  51. package/dist/core/compaction/compactor.test.js +122 -65
  52. package/dist/core/compaction/compactor.test.js.map +1 -1
  53. package/dist/core/compaction/token-estimator.d.ts +25 -1
  54. package/dist/core/compaction/token-estimator.d.ts.map +1 -1
  55. package/dist/core/compaction/token-estimator.js +86 -1
  56. package/dist/core/compaction/token-estimator.js.map +1 -1
  57. package/dist/core/compaction/token-estimator.test.js +147 -1
  58. package/dist/core/compaction/token-estimator.test.js.map +1 -1
  59. package/dist/core/compaction/tool-result-pruner.d.ts +37 -0
  60. package/dist/core/compaction/tool-result-pruner.d.ts.map +1 -0
  61. package/dist/core/compaction/tool-result-pruner.js +103 -0
  62. package/dist/core/compaction/tool-result-pruner.js.map +1 -0
  63. package/dist/core/compaction/tool-result-pruner.test.d.ts +2 -0
  64. package/dist/core/compaction/tool-result-pruner.test.d.ts.map +1 -0
  65. package/dist/core/compaction/tool-result-pruner.test.js +148 -0
  66. package/dist/core/compaction/tool-result-pruner.test.js.map +1 -0
  67. package/dist/core/event-bus.d.ts +2 -0
  68. package/dist/core/event-bus.d.ts.map +1 -1
  69. package/dist/core/event-bus.js.map +1 -1
  70. package/dist/core/ideal-review.d.ts +33 -0
  71. package/dist/core/ideal-review.d.ts.map +1 -1
  72. package/dist/core/ideal-review.js +78 -0
  73. package/dist/core/ideal-review.js.map +1 -1
  74. package/dist/core/ideal-review.test.js +43 -1
  75. package/dist/core/ideal-review.test.js.map +1 -1
  76. package/dist/core/index.d.ts +1 -1
  77. package/dist/core/index.d.ts.map +1 -1
  78. package/dist/core/index.js +1 -1
  79. package/dist/core/index.js.map +1 -1
  80. package/dist/core/lsp/client.d.ts +3 -0
  81. package/dist/core/lsp/client.d.ts.map +1 -1
  82. package/dist/core/lsp/client.js +20 -0
  83. package/dist/core/lsp/client.js.map +1 -1
  84. package/dist/core/lsp/manager.d.ts +32 -9
  85. package/dist/core/lsp/manager.d.ts.map +1 -1
  86. package/dist/core/lsp/manager.js +89 -37
  87. package/dist/core/lsp/manager.js.map +1 -1
  88. package/dist/core/lsp/manager.test.js +56 -4
  89. package/dist/core/lsp/manager.test.js.map +1 -1
  90. package/dist/core/project-discovery.d.ts +6 -9
  91. package/dist/core/project-discovery.d.ts.map +1 -1
  92. package/dist/core/project-discovery.js +35 -28
  93. package/dist/core/project-discovery.js.map +1 -1
  94. package/dist/core/project-discovery.test.js +148 -0
  95. package/dist/core/project-discovery.test.js.map +1 -1
  96. package/dist/core/resolve-start.test.js +1 -0
  97. package/dist/core/resolve-start.test.js.map +1 -1
  98. package/dist/core/run-lifecycle.d.ts +40 -0
  99. package/dist/core/run-lifecycle.d.ts.map +1 -0
  100. package/dist/core/run-lifecycle.js +95 -0
  101. package/dist/core/run-lifecycle.js.map +1 -0
  102. package/dist/core/run-lifecycle.test.d.ts +2 -0
  103. package/dist/core/run-lifecycle.test.d.ts.map +1 -0
  104. package/dist/core/run-lifecycle.test.js +64 -0
  105. package/dist/core/run-lifecycle.test.js.map +1 -0
  106. package/dist/core/session-compaction.d.ts +3 -0
  107. package/dist/core/session-compaction.d.ts.map +1 -1
  108. package/dist/core/session-compaction.js +14 -1
  109. package/dist/core/session-compaction.js.map +1 -1
  110. package/dist/core/session-compaction.test.js +5 -0
  111. package/dist/core/session-compaction.test.js.map +1 -1
  112. package/dist/core/session-manager.d.ts +33 -2
  113. package/dist/core/session-manager.d.ts.map +1 -1
  114. package/dist/core/session-manager.js +73 -2
  115. package/dist/core/session-manager.js.map +1 -1
  116. package/dist/core/session-manager.test.js +97 -2
  117. package/dist/core/session-manager.test.js.map +1 -1
  118. package/dist/core/session-preview.d.ts +11 -0
  119. package/dist/core/session-preview.d.ts.map +1 -0
  120. package/dist/core/session-preview.js +50 -0
  121. package/dist/core/session-preview.js.map +1 -0
  122. package/dist/core/settings-manager.d.ts +1 -0
  123. package/dist/core/settings-manager.d.ts.map +1 -1
  124. package/dist/core/settings-manager.js +3 -2
  125. package/dist/core/settings-manager.js.map +1 -1
  126. package/dist/core/subagent-manager.d.ts +42 -7
  127. package/dist/core/subagent-manager.d.ts.map +1 -1
  128. package/dist/core/subagent-manager.js +346 -30
  129. package/dist/core/subagent-manager.js.map +1 -1
  130. package/dist/core/subagent-manager.test.js +201 -5
  131. package/dist/core/subagent-manager.test.js.map +1 -1
  132. package/dist/core/subagent-store.d.ts +20 -0
  133. package/dist/core/subagent-store.d.ts.map +1 -0
  134. package/dist/core/subagent-store.js +177 -0
  135. package/dist/core/subagent-store.js.map +1 -0
  136. package/dist/core/subagent-store.test.d.ts +2 -0
  137. package/dist/core/subagent-store.test.d.ts.map +1 -0
  138. package/dist/core/subagent-store.test.js +94 -0
  139. package/dist/core/subagent-store.test.js.map +1 -0
  140. package/dist/modes/agent-home-mode.d.ts.map +1 -1
  141. package/dist/modes/agent-home-mode.js +10 -3
  142. package/dist/modes/agent-home-mode.js.map +1 -1
  143. package/dist/modes/serve-mode.d.ts.map +1 -1
  144. package/dist/modes/serve-mode.js +10 -3
  145. package/dist/modes/serve-mode.js.map +1 -1
  146. package/dist/modes/subagent-worker-mode.d.ts +2 -0
  147. package/dist/modes/subagent-worker-mode.d.ts.map +1 -1
  148. package/dist/modes/subagent-worker-mode.js +13 -3
  149. package/dist/modes/subagent-worker-mode.js.map +1 -1
  150. package/dist/tools/bash.d.ts +9 -0
  151. package/dist/tools/bash.d.ts.map +1 -1
  152. package/dist/tools/bash.js +28 -18
  153. package/dist/tools/bash.js.map +1 -1
  154. package/dist/tools/bash.test.d.ts +2 -0
  155. package/dist/tools/bash.test.d.ts.map +1 -0
  156. package/dist/tools/bash.test.js +56 -0
  157. package/dist/tools/bash.test.js.map +1 -0
  158. package/dist/tools/goals.d.ts +3 -3
  159. package/dist/tools/overflow.d.ts +12 -2
  160. package/dist/tools/overflow.d.ts.map +1 -1
  161. package/dist/tools/overflow.js +49 -4
  162. package/dist/tools/overflow.js.map +1 -1
  163. package/dist/tools/overflow.test.d.ts +2 -0
  164. package/dist/tools/overflow.test.d.ts.map +1 -0
  165. package/dist/tools/overflow.test.js +62 -0
  166. package/dist/tools/overflow.test.js.map +1 -0
  167. package/dist/tools/subagent-shared.d.ts +8 -0
  168. package/dist/tools/subagent-shared.d.ts.map +1 -1
  169. package/dist/tools/subagent-shared.js.map +1 -1
  170. package/dist/tools/subagent.d.ts +3 -8
  171. package/dist/tools/subagent.d.ts.map +1 -1
  172. package/dist/tools/subagent.js +10 -8
  173. package/dist/tools/subagent.js.map +1 -1
  174. package/dist/tools/subagent.test.js +17 -1
  175. package/dist/tools/subagent.test.js.map +1 -1
  176. package/dist/tools/tasks.d.ts +2 -2
  177. package/dist/tools/web-fetch.d.ts +1 -1
  178. package/dist/ui/App.d.ts +7 -3
  179. package/dist/ui/App.d.ts.map +1 -1
  180. package/dist/ui/App.js +68 -98
  181. package/dist/ui/App.js.map +1 -1
  182. package/dist/ui/components/Footer.d.ts +1 -0
  183. package/dist/ui/components/Footer.d.ts.map +1 -1
  184. package/dist/ui/components/Footer.js +3 -3
  185. package/dist/ui/components/Footer.js.map +1 -1
  186. package/dist/ui/components/Footer.test.d.ts +2 -0
  187. package/dist/ui/components/Footer.test.d.ts.map +1 -0
  188. package/dist/ui/components/Footer.test.js +16 -0
  189. package/dist/ui/components/Footer.test.js.map +1 -0
  190. package/dist/ui/components/ModelSelector.d.ts.map +1 -1
  191. package/dist/ui/components/ModelSelector.js +1 -0
  192. package/dist/ui/components/ModelSelector.js.map +1 -1
  193. package/dist/ui/components/SubAgentPanel.d.ts +2 -0
  194. package/dist/ui/components/SubAgentPanel.d.ts.map +1 -1
  195. package/dist/ui/components/SubAgentPanel.js +4 -2
  196. package/dist/ui/components/SubAgentPanel.js.map +1 -1
  197. package/dist/ui/hooks/useAgentLoop.d.ts +9 -6
  198. package/dist/ui/hooks/useAgentLoop.d.ts.map +1 -1
  199. package/dist/ui/hooks/useAgentLoop.js +68 -8
  200. package/dist/ui/hooks/useAgentLoop.js.map +1 -1
  201. package/dist/ui/hooks/useAgentLoop.test.js +31 -0
  202. package/dist/ui/hooks/useAgentLoop.test.js.map +1 -1
  203. package/dist/ui/hooks/useContextCompaction.d.ts +5 -8
  204. package/dist/ui/hooks/useContextCompaction.d.ts.map +1 -1
  205. package/dist/ui/hooks/useContextCompaction.js +77 -16
  206. package/dist/ui/hooks/useContextCompaction.js.map +1 -1
  207. package/dist/ui/hooks/useContextCompaction.test.d.ts +2 -0
  208. package/dist/ui/hooks/useContextCompaction.test.d.ts.map +1 -0
  209. package/dist/ui/hooks/useContextCompaction.test.js +135 -0
  210. package/dist/ui/hooks/useContextCompaction.test.js.map +1 -0
  211. package/dist/ui/hooks/useSessionPersistence.d.ts +4 -2
  212. package/dist/ui/hooks/useSessionPersistence.d.ts.map +1 -1
  213. package/dist/ui/hooks/useSessionPersistence.js +10 -1
  214. package/dist/ui/hooks/useSessionPersistence.js.map +1 -1
  215. package/dist/ui/hooks/useTerminalTitle.d.ts +3 -3
  216. package/dist/ui/hooks/useTerminalTitle.d.ts.map +1 -1
  217. package/dist/ui/hooks/useTerminalTitle.js +5 -9
  218. package/dist/ui/hooks/useTerminalTitle.js.map +1 -1
  219. package/dist/ui/login.d.ts.map +1 -1
  220. package/dist/ui/login.js +7 -2
  221. package/dist/ui/login.js.map +1 -1
  222. package/dist/ui/render.d.ts +7 -2
  223. package/dist/ui/render.d.ts.map +1 -1
  224. package/dist/ui/render.js +4 -4
  225. package/dist/ui/render.js.map +1 -1
  226. package/dist/ui/render.test.js +10 -1
  227. package/dist/ui/render.test.js.map +1 -1
  228. package/dist/ui/terminal-history.js +6 -2
  229. package/dist/ui/terminal-history.js.map +1 -1
  230. package/dist/ui/terminal-history.test.js +2 -2
  231. package/dist/ui/terminal-history.test.js.map +1 -1
  232. package/dist/ui/tui-history-parity.test.js +8 -1
  233. package/dist/ui/tui-history-parity.test.js.map +1 -1
  234. package/dist/utils/git.d.ts +2 -0
  235. package/dist/utils/git.d.ts.map +1 -1
  236. package/dist/utils/git.js +12 -0
  237. package/dist/utils/git.js.map +1 -1
  238. package/dist/utils/git.test.d.ts +2 -0
  239. package/dist/utils/git.test.d.ts.map +1 -0
  240. package/dist/utils/git.test.js +36 -0
  241. package/dist/utils/git.test.js.map +1 -0
  242. package/package.json +5 -5
  243. package/dist/utils/session-title.d.ts +0 -19
  244. package/dist/utils/session-title.d.ts.map +0 -1
  245. package/dist/utils/session-title.js +0 -82
  246. package/dist/utils/session-title.js.map +0 -1
@@ -1,4 +1,4 @@
1
- import { agentLoop, isAbortError } from "@prestyj/agent";
1
+ import { agentLoop, isAbortError, isUsageLimitError, } from "@prestyj/agent";
2
2
  import { ProviderError, } from "@prestyj/ai";
3
3
  import { EventBus } from "./event-bus.js";
4
4
  import { SlashCommandRegistry, createBuiltinCommands, } from "./slash-commands.js";
@@ -6,33 +6,55 @@ import { PROMPT_COMMANDS, getPromptCommand } from "./prompt-commands.js";
6
6
  import { loadCustomCommands } from "./custom-commands.js";
7
7
  import { SettingsManager } from "./settings-manager.js";
8
8
  import { AuthStorage } from "./auth-storage.js";
9
+ import { MOONSHOT_OAUTH_KEY } from "@prestyj/core";
9
10
  import { getClaudeCliUserAgent } from "./claude-code-version.js";
10
11
  import { kimiCodingHeaders, isKimiCodingEndpoint } from "./oauth/kimi.js";
11
12
  import { SessionManager, NOLAN_TURN_CUSTOM_KIND, AUTOPILOT_MARKER_CUSTOM_KIND, APP_MARKER_CUSTOM_KIND, } from "./session-manager.js";
12
13
  import { ExtensionLoader } from "./extensions/loader.js";
13
- import { shouldCompact, compact, getCompactionReserveTokens } from "./compaction/compactor.js";
14
- import { getAuthStorageKeys, getContextWindow, getModel, MODELS } from "./model-registry.js";
14
+ import { shouldCompact, compact } from "./compaction/compactor.js";
15
+ import { getAuthStorageKeys, getContextWindow, getModel, getToolResultCharLimit, MODELS, } from "./model-registry.js";
15
16
  import { discoverSkills } from "./skills.js";
16
17
  import { ensureAppDirs } from "../config.js";
17
18
  import { buildSystemPrompt } from "../system-prompt.js";
18
19
  import { createTools, createWebSearchTool, } from "../tools/index.js";
20
+ import { buildSubAgentCompletionFollowUp } from "./subagent-manager.js";
19
21
  import { applyAsyncSubagentPolicy } from "./subagent-policy.js";
20
22
  import { MCPClientManager, getAllMcpServers } from "./mcp/index.js";
21
23
  import { DeferredToolCatalog } from "./mcp/deferred-catalog.js";
22
24
  import { createToolSearchTool } from "../tools/tool-search.js";
23
25
  import { log } from "./logger.js";
24
- import { setEstimatorModel } from "./compaction/token-estimator.js";
26
+ import { setEstimatorModel, calibrateEstimatorFromUsage } from "./compaction/token-estimator.js";
27
+ import { calculateActiveContextTokens } from "./compaction/active-context.js";
28
+ import { pruneStaleToolResults } from "./compaction/tool-result-pruner.js";
25
29
  import { discoverAgents } from "./agents.js";
26
- import { generateSessionTitle } from "../utils/session-title.js";
27
30
  import { enhancePrompt } from "../utils/prompt-enhancer.js";
28
31
  import { detectProjectStack } from "./language-detector.js";
29
- import { evaluateIdealReview, buildIdealReviewMessage, detectTestDrift, } from "./ideal-review.js";
32
+ import { evaluateIdealReview, buildIdealReviewMessage, buildReviewCoverageMessage, withReviewCoverageRequirements, detectTestDrift, ReviewCoverageTracker, } from "./ideal-review.js";
30
33
  import { evaluateLoopBreak, buildLoopBreakMessage, ToolCallProgressTracker, detectTextRepetition, } from "./loop-breaker.js";
31
34
  import { buildRegroundingMessage } from "./regrounding.js";
32
35
  import { wrapSteeringText, STEERING_PREFIX } from "./steering.js";
36
+ import { findUserSessionPrompt, getUserSessionPrompt } from "./session-preview.js";
33
37
  import crypto from "node:crypto";
34
38
  import fs from "node:fs/promises";
35
39
  import path from "node:path";
40
+ // ── Tool-result policy ─────────────────────────────────────
41
+ /** Resolve the per-result cap passed to the agent loop for the active transport. */
42
+ export function resolveSessionToolResultCharLimit(model, provider, accountId) {
43
+ return (getToolResultCharLimit(model, { provider, accountId }) ??
44
+ Math.floor(getContextWindow(model, { provider, accountId }) * 3.5 * 0.3));
45
+ }
46
+ /**
47
+ * Aggregate budget for ALL tool results produced in one assistant turn.
48
+ * Individual results are already capped, but wide parallel fan-outs (GPT-5.6's
49
+ * signature behavior) were observed injecting 100k+ uncached tokens in a single
50
+ * turn. ~15% of the context window in chars (1 token ≈ 3.5 chars), floored at
51
+ * 100KB so small windows still fit two full-size reads, ceilinged at 240KB so
52
+ * 1M-context models don't waive the budget entirely.
53
+ */
54
+ export function resolveSessionTurnToolResultCharLimit(model, provider, accountId) {
55
+ const contextChars = getContextWindow(model, { provider, accountId }) * 3.5;
56
+ return Math.max(100_000, Math.min(Math.floor(contextChars * 0.15), 240_000));
57
+ }
36
58
  // ── Agent Session ──────────────────────────────────────────
37
59
  export class AgentSession {
38
60
  eventBus = new EventBus();
@@ -59,6 +81,7 @@ export class AgentSession {
59
81
  // display only, persisted + reloaded so a resumed session shows the same
60
82
  // transcript rows the live run showed.
61
83
  appMarkers = [];
84
+ turnMetrics = [];
62
85
  tools = [];
63
86
  /** Rebuilds the read tool for a new model (video byte cap is baked in at
64
87
  * creation). Called from switchModel so video-capable models get the
@@ -84,10 +107,17 @@ export class AgentSession {
84
107
  hookProgressTracker = new ToolCallProgressTracker();
85
108
  hookFileEditCounts = new Map();
86
109
  hookToolCalls = new Map();
87
- idealReviewInjected = false;
110
+ idealReviewPhase = "idle";
111
+ /** Runtime-only suppression while Nolan owns verification in autopilot mode. */
112
+ idealReviewSuppressed = false;
113
+ reviewCoverage;
88
114
  loopBreakInjected = false;
89
115
  regroundingInjected = false;
90
116
  compactionOccurred = false;
117
+ lastCompactionCompacted = false;
118
+ compactionRetryAfter = 0;
119
+ /** Latest provider count, anchored to the assistant response it measured. */
120
+ providerContext = null;
91
121
  originalRequest = "";
92
122
  // Messages queued by the user while a run is in flight. Drained at the
93
123
  // mid-loop steering boundary (user steering wins over the hooks), mirroring
@@ -125,6 +155,10 @@ export class AgentSession {
125
155
  * model emits step-completion markers the UI's plan-progress widget reads. */
126
156
  approvedPlanPath;
127
157
  sessionId = "";
158
+ /** Stable identity shared by compaction and approved-plan checkpoint files. */
159
+ conversationId = "";
160
+ /** Original user-authored prompt, retained when internal messages replace history. */
161
+ sessionPreview = "";
128
162
  /** Runtime conversation identity for provider transport headers. Transient
129
163
  * children need one even though they intentionally have no persisted session. */
130
164
  transportSessionId = crypto.randomUUID();
@@ -138,6 +172,7 @@ export class AgentSession {
138
172
  this.provider = options.provider;
139
173
  this.model = options.model;
140
174
  this.cwd = options.cwd;
175
+ this.reviewCoverage = new ReviewCoverageTracker(this.cwd);
141
176
  this.baseUrl = options.baseUrl;
142
177
  this.maxTokens = this.resolveMaxTokens(options.model);
143
178
  this.thinkingLevel = options.thinkingLevel;
@@ -203,6 +238,12 @@ export class AgentSession {
203
238
  model: this.model,
204
239
  lspDiagnostics: this.settingsManager.get("lspDiagnostics"),
205
240
  authStorage: this.authStorage,
241
+ onFileRead: (filePath) => this.reviewCoverage.recordRead(filePath),
242
+ onFileMutated: (filePath) => {
243
+ const relative = path.relative(this.cwd, filePath) || path.basename(filePath);
244
+ this.hookFileEditCounts.set(relative, (this.hookFileEditCounts.get(relative) ?? 0) + 1);
245
+ this.reviewCoverage.recordChanged(filePath);
246
+ },
206
247
  // Lazy — sessionId/model/provider can change after createTools() runs, so
207
248
  // sub-agent spawns read the current parent state at execution time.
208
249
  getProvider: () => this.provider,
@@ -282,6 +323,8 @@ export class AgentSession {
282
323
  else {
283
324
  await this.createNewSession();
284
325
  }
326
+ if (this.sessionId)
327
+ await this.subAgentManager?.hydrate(this.sessionId);
285
328
  // EZ Coder owns its command registry. Other agents start with an isolated
286
329
  // empty registry and can register their own commands in their own file.
287
330
  if (this.opts.coderSlashCommands !== false) {
@@ -576,7 +619,8 @@ export class AgentSession {
576
619
  this.hookProgressTracker.reset();
577
620
  this.hookFileEditCounts.clear();
578
621
  this.hookToolCalls.clear();
579
- this.idealReviewInjected = false;
622
+ this.reviewCoverage.reset();
623
+ this.idealReviewPhase = "idle";
580
624
  this.loopBreakInjected = false;
581
625
  this.regroundingInjected = false;
582
626
  this.compactionOccurred = false;
@@ -587,7 +631,7 @@ export class AgentSession {
587
631
  * the same signals the TUI's useAgentLoop collects, so the loop-break and
588
632
  * ideal-review decisions match across the CLI and the app.
589
633
  */
590
- trackHookEvent(event) {
634
+ async trackHookEvent(event) {
591
635
  switch (event.type) {
592
636
  case "text_delta":
593
637
  this.hookText += event.text;
@@ -610,13 +654,6 @@ export class AgentSession {
610
654
  this.hookStats.bashCalls += 1;
611
655
  this.hookConsecutiveFailures = event.isError ? this.hookConsecutiveFailures + 1 : 0;
612
656
  this.hookRepeatedNoProgressCalls = this.hookProgressTracker.record(name, args, event.result, event.isError);
613
- if ((name === "edit" || name === "write") && args) {
614
- const filePath = args.file_path;
615
- if (typeof filePath === "string") {
616
- const fileNext = (this.hookFileEditCounts.get(filePath) ?? 0) + 1;
617
- this.hookFileEditCounts.set(filePath, fileNext);
618
- }
619
- }
620
657
  if (name === "edit" && !event.isError) {
621
658
  const diff = event.details?.diff ?? event.result;
622
659
  const added = (diff.match(/^\+[^+]/gm) ?? []).length;
@@ -627,6 +664,14 @@ export class AgentSession {
627
664
  }
628
665
  case "turn_end":
629
666
  this.hookStats.turns = event.turn;
667
+ for (let index = this.messages.length - 1; index >= 0; index--) {
668
+ const anchor = this.messages[index];
669
+ if (anchor?.role === "assistant") {
670
+ this.providerContext = { usage: { ...event.usage }, anchor };
671
+ break;
672
+ }
673
+ }
674
+ await this.persistTurnMetric(event);
630
675
  break;
631
676
  }
632
677
  }
@@ -686,15 +731,35 @@ export class AgentSession {
686
731
  return null;
687
732
  }
688
733
  /**
689
- * Pre-stop follow-up hook: runs the ideal review once, when the agent would
690
- * otherwise finish and the change set is substantial enough to warrant it.
734
+ * Pre-stop Ideal review phase machine. Once review starts, completion is
735
+ * blocked until harness-owned post-injection reads cover every changed file.
691
736
  */
692
737
  getHookFollowUpMessages() {
693
- if (this.opts.selfCorrectionHooks === false)
738
+ const childCompletionFollowUp = buildSubAgentCompletionFollowUp(this.subAgentManager);
739
+ if (childCompletionFollowUp)
740
+ return childCompletionFollowUp;
741
+ if (this.opts.selfCorrectionHooks === false || this.idealReviewSuppressed)
694
742
  return null;
695
- if (!this.settingsManager.get("idealReviewEnabled"))
743
+ if (this.idealReviewPhase === "reviewing") {
744
+ const coverage = this.reviewCoverage.evidence();
745
+ const lspEvidence = this.reviewLspEvidence(coverage.expected);
746
+ log("INFO", "ideal", "Ideal review coverage check", {
747
+ covered: coverage.covered,
748
+ missing: coverage.missing,
749
+ lspLowConfidence: lspEvidence.lowConfidence,
750
+ lspMissing: lspEvidence.missing,
751
+ });
752
+ if (coverage.missing.length > 0) {
753
+ return [
754
+ this.withReviewLspEvidence(buildReviewCoverageMessage(coverage.missing), lspEvidence),
755
+ ];
756
+ }
757
+ this.idealReviewPhase = "complete";
696
758
  return null;
697
- if (this.idealReviewInjected)
759
+ }
760
+ if (this.idealReviewPhase === "complete")
761
+ return null;
762
+ if (!this.settingsManager.get("idealReviewEnabled"))
698
763
  return null;
699
764
  const decision = evaluateIdealReview(this.hookStats);
700
765
  // Test drift fires the review even on a small change the score would skip:
@@ -702,9 +767,50 @@ export class AgentSession {
702
767
  const driftedFiles = detectTestDrift(this.hookFileEditCounts.keys(), this.cwd).slice(0, 5);
703
768
  if (!decision.shouldReview && driftedFiles.length === 0)
704
769
  return null;
705
- this.idealReviewInjected = true;
706
- this.eventBus.emit("hook", { kind: "ideal" });
707
- return [buildIdealReviewMessage(decision.reasons, driftedFiles)];
770
+ this.reviewCoverage.start(this.hookFileEditCounts.keys());
771
+ this.idealReviewPhase = "reviewing";
772
+ const coverage = this.reviewCoverage.evidence();
773
+ const lspEvidence = this.reviewLspEvidence(coverage.expected);
774
+ this.eventBus.emit("hook", {
775
+ kind: "ideal",
776
+ coverageExpected: coverage.expected,
777
+ coverageMissing: coverage.missing,
778
+ });
779
+ log("INFO", "ideal", "Injecting ideal review before final response", {
780
+ coverageExpected: coverage.expected,
781
+ coverageMissing: coverage.missing,
782
+ lspLowConfidence: lspEvidence.lowConfidence,
783
+ lspMissing: lspEvidence.missing,
784
+ });
785
+ return [
786
+ this.withReviewLspEvidence(withReviewCoverageRequirements(buildIdealReviewMessage(decision.reasons, driftedFiles), coverage.missing), lspEvidence),
787
+ ];
788
+ }
789
+ reviewLspEvidence(files) {
790
+ const lowConfidence = [];
791
+ const missing = [];
792
+ for (const filePath of files) {
793
+ const outcome = this.lspManager?.getLatestOutcome(filePath);
794
+ if (outcome?.kind === "low_confidence")
795
+ lowConfidence.push(filePath);
796
+ else if (outcome?.kind !== "clean" && outcome?.kind !== "diagnostics")
797
+ missing.push(filePath);
798
+ }
799
+ return { lowConfidence, missing };
800
+ }
801
+ withReviewLspEvidence(message, evidence) {
802
+ if (evidence.lowConfidence.length === 0 && evidence.missing.length === 0)
803
+ return message;
804
+ const notes = [
805
+ ...(evidence.lowConfidence.length > 0
806
+ ? [`Diagnostics are low confidence while indexing: ${evidence.lowConfidence.join(", ")}.`]
807
+ : []),
808
+ ...(evidence.missing.length > 0
809
+ ? [`Diagnostics evidence is unavailable or missing: ${evidence.missing.join(", ")}.`]
810
+ : []),
811
+ "Do not describe those files as compiler-clean without other evidence.",
812
+ ];
813
+ return { role: "user", content: `${String(message.content)}\n\n${notes.join(" ")}` };
708
814
  }
709
815
  /** Auto-compact if needed, run agent loop with auth retry, and persist messages. */
710
816
  async runLoop() {
@@ -737,24 +843,45 @@ export class AgentSession {
737
843
  this.lastAccountId = creds.accountId;
738
844
  // Auto-compact if needed. This must happen after credential resolution so
739
845
  // OpenAI OAuth/Codex sessions use the Codex product context window instead
740
- // of the public API model window.
741
- if (this.settingsManager.get("autoCompact")) {
846
+ // of the public API model window. Failed/no-op attempts cool down across
847
+ // prompts; provider overflow recovery still bypasses this path entirely.
848
+ if (this.settingsManager.get("autoCompact") && Date.now() >= this.compactionRetryAfter) {
742
849
  const contextWindow = getContextWindow(this.model, {
743
850
  provider: this.provider,
744
851
  accountId: creds.accountId,
745
852
  });
746
853
  const threshold = this.settingsManager.get("compactThreshold");
747
- // Reserve headroom for this model's real output budget (e.g. GPT-5.5 over
748
- // Codex OAuth: 272K window but up to 128K max output) — without this the
749
- // default 16K reserve lets compaction skip until input alone is near the
750
- // window, then `input + max_tokens` exceeds it and the provider rejects
751
- // the turn outright with "exceeds the context window". Mirrors the TUI's
752
- // useContextCompaction hook.
753
- const reserveTokens = getCompactionReserveTokens(this.maxTokens);
754
- if (shouldCompact(this.messages, contextWindow, threshold, undefined, reserveTokens)) {
755
- await this.compact(creds);
756
- // Re-grounding hook keys off this — the context was just summarized.
757
- this.compactionOccurred = true;
854
+ let activeTokens;
855
+ if (this.providerContext) {
856
+ const anchorIndex = this.messages.lastIndexOf(this.providerContext.anchor);
857
+ if (anchorIndex >= 0) {
858
+ activeTokens = calculateActiveContextTokens(this.messages, {
859
+ usage: this.providerContext.usage,
860
+ pendingMessages: this.messages.slice(anchorIndex + 1),
861
+ });
862
+ }
863
+ else {
864
+ this.providerContext = null;
865
+ }
866
+ }
867
+ if (shouldCompact(this.messages, contextWindow, threshold, activeTokens)) {
868
+ try {
869
+ await this.compact(creds);
870
+ if (this.lastCompactionCompacted) {
871
+ // Re-grounding hook keys off this — the context was just summarized.
872
+ this.compactionOccurred = true;
873
+ this.compactionRetryAfter = 0;
874
+ }
875
+ else {
876
+ this.compactionRetryAfter = Date.now() + 30_000;
877
+ }
878
+ }
879
+ catch (error) {
880
+ this.compactionRetryAfter = Date.now() + 30_000;
881
+ if (isAbortError(error) || this.opts.signal?.aborted)
882
+ throw error;
883
+ log("WARN", "compaction", `Pre-run compaction failed; cooling down for 30s: ${error instanceof Error ? error.message : String(error)}`);
884
+ }
758
885
  }
759
886
  }
760
887
  const userAgent = this.provider === "anthropic" ? await getClaudeCliUserAgent() : undefined;
@@ -788,38 +915,121 @@ export class AgentSession {
788
915
  supportsImages: modelInfo?.supportsImages,
789
916
  supportsVideo: modelInfo?.supportsVideo,
790
917
  userAgent,
791
- // clearToolUses disabled — causes model to output unsolicited context summaries
792
- // Single tool result shouldn't exceed 30% of context window (in chars)
793
- maxToolResultChars: Math.floor(getContextWindow(this.model, { provider: this.provider, accountId }) * 3.5 * 0.3),
918
+ // Codex caps each tool output at 10K tokens. Other transports retain the
919
+ // generic 30%-of-context allowance used before this provider policy.
920
+ maxToolResultChars: resolveSessionToolResultCharLimit(this.model, this.provider, accountId),
921
+ // Aggregate per-turn budget across parallel tool results (fan-out guard).
922
+ maxTurnToolResultChars: resolveSessionTurnToolResultCharLimit(this.model, this.provider, accountId),
794
923
  // Self-correction hooks (same as the TUI): loop-break + re-grounding are
795
924
  // polled mid-loop; the ideal review is polled when the agent would stop.
796
925
  getSteeringMessages: () => this.getHookSteeringMessages(),
797
926
  getFollowUpMessages: () => this.getHookFollowUpMessages(),
798
- // Overflow recovery: the loop calls this with { force: true } when the
799
- // provider rejects a turn as too large (request_too_large / context
800
- // overflow). Force-compact the in-flight history and hand it back so the
801
- // loop retries with a smaller request, instead of surfacing the error.
802
- // Without this the desktop app (which drives the loop through
803
- // AgentSession, not the TUI's useContextCompaction hook) had NO auto
804
- // recovery on 413 — the error went straight to the user. The non-force
805
- // pre-call invocations pass through untouched: pre-turn compaction is
806
- // already handled above, so we only act on the overflow force path.
807
- // `loopMessages === this.messages` (prepareDynamicContext returns it by
808
- // reference) and the post-loop `this.messages = loopMessages` re-sync
809
- // keeps persistence correct after compact() swaps the array.
927
+ // Check authoritative provider usage before every model/tool step.
928
+ // Forced overflow recovery bypasses settings and cooldown; proactive
929
+ // checks honor both and estimate only messages unseen by the provider.
810
930
  transformContext: async (messages, transformOpts) => {
811
- if (!transformOpts?.force)
931
+ if (transformOpts.usage) {
932
+ const anchorIndex = messages.length - transformOpts.pendingMessages.length - 1;
933
+ const anchor = messages[anchorIndex];
934
+ if (anchor?.role === "assistant") {
935
+ this.providerContext = { usage: { ...transformOpts.usage }, anchor };
936
+ // Feed the authoritative usage back into the token estimator so
937
+ // char-based estimates track this session's real tokenizer.
938
+ calibrateEstimatorFromUsage(messages.slice(0, anchorIndex), transformOpts.usage);
939
+ }
940
+ }
941
+ const force = transformOpts.force === true;
942
+ if (!force) {
943
+ if (!this.settingsManager.get("autoCompact"))
944
+ return messages;
945
+ // Cheap stale-tool-output pruning before the expensive LLM
946
+ // compaction check. In-place mutation preserves anchors; drop the
947
+ // retained usage afterwards since it counted the pruned content.
948
+ const pruneResult = pruneStaleToolResults(messages);
949
+ if (pruneResult.pruned) {
950
+ this.providerContext = null;
951
+ log("INFO", "compaction", "Pruned stale tool outputs", {
952
+ prunedResults: String(pruneResult.prunedResults),
953
+ freedTokens: String(pruneResult.freedTokens),
954
+ });
955
+ }
956
+ if (Date.now() < this.compactionRetryAfter)
957
+ return messages;
958
+ // The turn's own usage also counted the pruned content — after a
959
+ // prune, fall back to estimating the (now smaller) history so the
960
+ // freed tokens actually defer the LLM compaction.
961
+ let usage = pruneResult.pruned ? undefined : transformOpts.usage;
962
+ let pendingMessages = transformOpts.pendingMessages;
963
+ if (!usage && this.providerContext) {
964
+ const anchorIndex = messages.lastIndexOf(this.providerContext.anchor);
965
+ if (anchorIndex >= 0) {
966
+ usage = this.providerContext.usage;
967
+ pendingMessages = messages.slice(anchorIndex + 1);
968
+ }
969
+ else {
970
+ this.providerContext = null;
971
+ }
972
+ }
973
+ const contextWindow = getContextWindow(this.model, {
974
+ provider: this.provider,
975
+ accountId,
976
+ });
977
+ const threshold = this.settingsManager.get("compactThreshold");
978
+ const activeTokens = calculateActiveContextTokens(messages, {
979
+ usage,
980
+ pendingMessages,
981
+ });
982
+ if (!shouldCompact(messages, contextWindow, threshold, activeTokens))
983
+ return messages;
984
+ }
985
+ // compact() operates on this.messages, while an earlier transform may
986
+ // have replaced the loop's in-flight array. Rebind before every attempt
987
+ // so the current tool results are included in the summary.
988
+ this.messages = messages;
989
+ try {
990
+ await this.compact({
991
+ accessToken: apiKey,
992
+ accountId,
993
+ projectId,
994
+ baseUrl: effectiveBaseUrl,
995
+ });
996
+ }
997
+ catch (error) {
998
+ this.messages = messages;
999
+ this.compactionRetryAfter = Date.now() + 30_000;
1000
+ if (force || isAbortError(error) || this.opts.signal?.aborted)
1001
+ throw error;
1002
+ log("WARN", "compaction", `In-flight compaction failed; cooling down for 30s: ${error instanceof Error ? error.message : String(error)}`);
812
1003
  return messages;
813
- await this.compact();
1004
+ }
1005
+ if (!this.lastCompactionCompacted) {
1006
+ this.messages = messages;
1007
+ this.compactionRetryAfter = Date.now() + 30_000;
1008
+ return messages;
1009
+ }
1010
+ this.compactionRetryAfter = 0;
814
1011
  this.compactionOccurred = true;
815
1012
  return this.messages;
816
1013
  },
817
1014
  });
818
1015
  for await (const event of generator) {
819
- this.trackHookEvent(event);
1016
+ await this.trackHookEvent(event);
820
1017
  this.eventBus.forwardAgentEvent(event);
821
1018
  }
822
1019
  };
1020
+ const clearInvalidStaticApiKey = async (error) => {
1021
+ if (!(error instanceof ProviderError) || error.statusCode !== 401)
1022
+ return false;
1023
+ if (!(await this.authStorage.isStaticApiKey(this.provider)))
1024
+ return false;
1025
+ // Clear whichever key actually resolved (the request may have used a
1026
+ // fallback key, not the model's first preference).
1027
+ const badKey = (await this.authStorage.pickStorageKey(this.currentAuthStorageKeys())) ??
1028
+ this.currentAuthStorageKeys()[0];
1029
+ log("WARN", "auth", `Got 401 for ${this.provider} (${badKey}) — API key is invalid or revoked`);
1030
+ await this.authStorage.clearCredentials(badKey);
1031
+ return true;
1032
+ };
823
1033
  try {
824
1034
  await runAgentLoop(creds.accessToken, creds.accountId, creds.projectId);
825
1035
  }
@@ -828,21 +1038,48 @@ export class AgentSession {
828
1038
  if (isAbortError(err) || this.opts.signal?.aborted) {
829
1039
  return;
830
1040
  }
831
- if (err instanceof ProviderError && err.statusCode === 401) {
1041
+ // Kimi OAuth plan ran out of usage (hard usage-limit stop, or an HTTP 402
1042
+ // billing stop). If the user ALSO configured a Moonshot API key, mark
1043
+ // the OAuth credential usage-exhausted (honoring the provider-stated
1044
+ // reset time when present) and retry this turn on the API key — OAuth
1045
+ // stays the preferred credential and resumes automatically once the mark
1046
+ // lapses. A generic 429 is deliberately excluded: it may be a transient
1047
+ // rate limit and must not silently switch the user to a billed API key.
1048
+ // Guarded on the Kimi managed endpoint actually being in use: if the API
1049
+ // key was already active, the same error means BOTH are out and must surface.
1050
+ if (this.provider === "moonshot" &&
1051
+ !this.baseUrl &&
1052
+ isKimiCodingEndpoint(creds.baseUrl) &&
1053
+ (isUsageLimitError(err) || (err instanceof ProviderError && err.statusCode === 402)) &&
1054
+ (await this.authStorage.hasCredentials("moonshot"))) {
1055
+ const resetsAt = err instanceof ProviderError ? err.resetsAt : undefined;
1056
+ await this.authStorage.markUsageExhausted(MOONSHOT_OAUTH_KEY, resetsAt);
1057
+ log("WARN", "auth", "Kimi OAuth usage limit reached — retrying this turn on the Moonshot API key", { resetsAt: resetsAt !== undefined ? String(resetsAt) : "unknown" });
1058
+ creds = await this.authStorage.resolveCredentials(this.provider, {
1059
+ storageKeys: this.currentAuthStorageKeys(),
1060
+ });
1061
+ this.lastAccountId = creds.accountId;
1062
+ // The runAgentLoop closure re-reads `creds`, so the retry picks up the
1063
+ // API key's baseUrl (api.moonshot.ai) and drops the Kimi coding headers.
1064
+ try {
1065
+ await runAgentLoop(creds.accessToken, creds.accountId, creds.projectId);
1066
+ }
1067
+ catch (fallbackErr) {
1068
+ // The fallback is inside this catch branch, so its errors do not pass
1069
+ // through the outer 401 handler. Clear a rejected API key explicitly
1070
+ // before surfacing the error and prompting the user to log in again.
1071
+ await clearInvalidStaticApiKey(fallbackErr);
1072
+ throw fallbackErr;
1073
+ }
1074
+ }
1075
+ else if (err instanceof ProviderError && err.statusCode === 401) {
832
1076
  // Static API-key providers (GLM, Moonshot API key, etc.) have no refresh
833
1077
  // mechanism — retrying with the same key is pointless. Clear the
834
1078
  // credential and surface the error so the user re-logins. Kimi OAuth
835
1079
  // (active for `moonshot` when present) is refreshable, so it falls
836
1080
  // through to the force-refresh path below.
837
- if (await this.authStorage.isStaticApiKey(this.provider)) {
838
- // Clear whichever key actually resolved (the request may have used
839
- // a fallback key, not the model's first preference).
840
- const badKey = (await this.authStorage.pickStorageKey(this.currentAuthStorageKeys())) ??
841
- this.currentAuthStorageKeys()[0];
842
- log("WARN", "auth", `Got 401 for ${this.provider} (${badKey}) — API key is invalid or revoked`);
843
- await this.authStorage.clearCredentials(badKey);
1081
+ if (await clearInvalidStaticApiKey(err))
844
1082
  throw err;
845
- }
846
1083
  log("INFO", "auth", "Got 401, force-refreshing token and retrying");
847
1084
  creds = await this.authStorage.resolveCredentials(this.provider, {
848
1085
  forceRefresh: true,
@@ -867,6 +1104,7 @@ export class AgentSession {
867
1104
  if (provider)
868
1105
  this.provider = provider;
869
1106
  this.model = model;
1107
+ this.providerContext = null;
870
1108
  // Keep host-provided option closures (notably chat delegation) aligned with
871
1109
  // the live selection after an in-session model switch.
872
1110
  this.opts.provider = this.provider;
@@ -944,6 +1182,7 @@ export class AgentSession {
944
1182
  }
945
1183
  }
946
1184
  async compact(existingCredentials) {
1185
+ this.lastCompactionCompacted = false;
947
1186
  const creds = existingCredentials ??
948
1187
  (await this.authStorage.resolveCredentials(this.provider, {
949
1188
  storageKeys: this.currentAuthStorageKeys(),
@@ -964,6 +1203,15 @@ export class AgentSession {
964
1203
  signal: this.opts.signal,
965
1204
  });
966
1205
  this.messages = result.messages;
1206
+ this.lastCompactionCompacted = result.result.compacted;
1207
+ if (!result.result.compacted) {
1208
+ this.eventBus.emit("compaction_end", {
1209
+ originalCount: result.result.originalCount,
1210
+ newCount: result.result.newCount,
1211
+ });
1212
+ return;
1213
+ }
1214
+ this.providerContext = null;
967
1215
  // Transient sessions (Nolan chat/autopilot, subagent spawns) must NEVER touch
968
1216
  // the session store: without this guard, the first auto-compaction called
969
1217
  // sessionManager.create() and assigned a real sessionPath, silently turning
@@ -976,9 +1224,14 @@ export class AgentSession {
976
1224
  else {
977
1225
  // Persist compacted messages to a new session file so `ezcoder continue`
978
1226
  // picks up the compacted state instead of the full original history.
979
- const session = await this.sessionManager.create(this.cwd, this.provider, this.model);
1227
+ const session = await this.sessionManager.create(this.cwd, this.provider, this.model, {
1228
+ conversationId: this.conversationId || undefined,
1229
+ preview: this.sessionPreview || undefined,
1230
+ });
980
1231
  this.sessionId = session.id;
1232
+ this.conversationId = session.header.conversationId ?? session.id;
981
1233
  this.sessionPath = session.path;
1234
+ await this.subAgentManager?.rebindParentSession(this.sessionId);
982
1235
  // Write compacted messages (skip system — it's rebuilt on load)
983
1236
  for (const msg of this.messages) {
984
1237
  if (msg.role === "system")
@@ -986,7 +1239,8 @@ export class AgentSession {
986
1239
  await this.persistMessage(msg);
987
1240
  }
988
1241
  this.lastPersistedIndex = this.messages.length;
989
- // Carry Nolan's advisory turns into the new file so they survive compaction.
1242
+ // Carry evidence and Nolan's advisory turns into the new file so they survive compaction.
1243
+ await this.rePersistTurnMetrics();
990
1244
  await this.rePersistNolanTurns();
991
1245
  await this.rePersistAutopilotMarkers();
992
1246
  await this.rePersistAppMarkers();
@@ -1002,7 +1256,13 @@ export class AgentSession {
1002
1256
  newCount: result.result.newCount,
1003
1257
  });
1004
1258
  }
1005
- async newSession() {
1259
+ async newSession(preserveConversation = false) {
1260
+ // Approved-plan execution is a clean checkpoint of the same conversation;
1261
+ // explicit new sessions reset the conversation identity.
1262
+ if (!preserveConversation) {
1263
+ this.conversationId = "";
1264
+ this.sessionPreview = "";
1265
+ }
1006
1266
  // A fresh session drops any in-flight plan state so its prompt is clean.
1007
1267
  this.planModeRef.current = false;
1008
1268
  this.approvedPlanPath = undefined;
@@ -1013,6 +1273,7 @@ export class AgentSession {
1013
1273
  this.nolanTurns = [];
1014
1274
  this.autopilotMarkers = [];
1015
1275
  this.appMarkers = [];
1276
+ this.turnMetrics = [];
1016
1277
  const basePrompt = this.customSystemPrompt ??
1017
1278
  (await buildSystemPrompt(this.cwd, this.skills, false, undefined, this.tools.map((tool) => tool.name), undefined, this.provider));
1018
1279
  this.baseSystemPrompt = basePrompt;
@@ -1027,16 +1288,21 @@ export class AgentSession {
1027
1288
  // polluted the project's session list.
1028
1289
  if (this.opts.transient) {
1029
1290
  this.sessionId = "";
1291
+ this.conversationId = "";
1292
+ this.sessionPreview = "";
1030
1293
  this.sessionPath = "";
1031
1294
  this.lastPersistedIndex = this.messages.length;
1032
1295
  }
1033
1296
  else {
1034
1297
  await this.createNewSession();
1298
+ await this.subAgentManager?.resetParentSession(this.sessionId);
1035
1299
  }
1036
1300
  this.eventBus.emit("session_start", { sessionId: this.sessionId });
1037
1301
  }
1038
1302
  async loadSession(sessionPath) {
1039
1303
  await this.loadExistingSession(sessionPath);
1304
+ if (this.sessionId)
1305
+ await this.subAgentManager?.hydrate(this.sessionId);
1040
1306
  this.eventBus.emit("session_start", { sessionId: this.sessionId });
1041
1307
  }
1042
1308
  /**
@@ -1096,6 +1362,18 @@ export class AgentSession {
1096
1362
  getPlanMode() {
1097
1363
  return this.planModeRef.current;
1098
1364
  }
1365
+ /**
1366
+ * Suppress only the pre-final Ideal self-review for this live session.
1367
+ * Autopilot uses this while Nolan independently owns verification; loop-break
1368
+ * and post-compaction re-grounding remain active.
1369
+ */
1370
+ setIdealReviewSuppressed(suppressed) {
1371
+ this.idealReviewSuppressed = suppressed;
1372
+ if (suppressed) {
1373
+ this.idealReviewPhase = "idle";
1374
+ this.reviewCoverage.reset();
1375
+ }
1376
+ }
1099
1377
  /** Queue a user message (optionally with attachments) to be injected mid-run
1100
1378
  * as steering. Returns the new queue length. No-op semantics are the caller's
1101
1379
  * concern. */
@@ -1206,6 +1484,39 @@ export class AgentSession {
1206
1484
  getMessages() {
1207
1485
  return this.messages;
1208
1486
  }
1487
+ getTurnMetrics() {
1488
+ return this.turnMetrics.map((metric) => ({
1489
+ ...metric,
1490
+ usage: { ...metric.usage },
1491
+ timing: { ...metric.timing },
1492
+ cost: { ...metric.cost },
1493
+ }));
1494
+ }
1495
+ async persistTurnMetric(event) {
1496
+ const payload = {
1497
+ version: 1,
1498
+ turn: event.turn,
1499
+ provider: this.provider,
1500
+ model: this.model,
1501
+ stopReason: event.stopReason,
1502
+ usage: { ...event.usage },
1503
+ timing: { ...event.timing },
1504
+ cost: {
1505
+ status: "unavailable",
1506
+ reason: "No authoritative effective-dated provider pricing is available",
1507
+ },
1508
+ };
1509
+ this.turnMetrics.push(payload);
1510
+ if (this.sessionPath)
1511
+ await this.sessionManager.appendTurnMetric(this.sessionPath, payload);
1512
+ }
1513
+ async rePersistTurnMetrics() {
1514
+ if (!this.sessionPath)
1515
+ return;
1516
+ for (const metric of this.turnMetrics) {
1517
+ await this.sessionManager.appendTurnMetric(this.sessionPath, metric);
1518
+ }
1519
+ }
1209
1520
  /** Nolan Grout (mentor) turns recorded against this session, in record order. Used
1210
1521
  * by the host to interleave Nolan's advisory exchanges back into the transcript
1211
1522
  * on resume. Never part of the LLM message history. */
@@ -1360,46 +1671,12 @@ export class AgentSession {
1360
1671
  await this.sessionManager.appendEntry(this.sessionPath, entry);
1361
1672
  }
1362
1673
  }
1363
- /**
1364
- * Generate a short LLM session title from the conversation so far (first user
1365
- * message + first assistant reply). Best-effort; returns null on failure or
1366
- * when there's no user message yet. Uses the cheapest model for the provider.
1367
- */
1368
- async generateTitle() {
1369
- const extractText = (content) => typeof content === "string"
1370
- ? content
1371
- : content
1372
- .map((c) => c.type === "text" && "text" in c && typeof c.text === "string" ? c.text : "")
1373
- .join(" ");
1374
- const userMsg = this.messages.find((m) => m.role === "user");
1375
- const assistantMsg = this.messages.find((m) => m.role === "assistant");
1376
- const userText = userMsg ? extractText(userMsg.content) : "";
1377
- if (!userText.trim())
1378
- return null;
1379
- try {
1380
- const creds = await this.authStorage.resolveCredentials(this.provider, {
1381
- storageKeys: this.currentAuthStorageKeys(),
1382
- });
1383
- const title = await generateSessionTitle({
1384
- provider: this.provider,
1385
- userMessage: userText,
1386
- assistantPreview: assistantMsg ? extractText(assistantMsg.content).slice(0, 200) : "",
1387
- apiKey: creds.accessToken,
1388
- baseUrl: this.baseUrl ?? creds.baseUrl,
1389
- accountId: creds.accountId,
1390
- });
1391
- return title || null;
1392
- }
1393
- catch {
1394
- return null;
1395
- }
1396
- }
1397
1674
  /**
1398
1675
  * Rewrite a draft prompt into a tighter, terminology-correct version using
1399
1676
  * the ACTIVE provider/model. A stateless one-off LLM call (no agent loop, no
1400
1677
  * tools, no session mutation) — safe to run even mid-run. Returns the plain
1401
1678
  * enhanced text plus typed segments marking each corrected term. Errors throw
1402
- * so the caller can surface them (unlike best-effort title generation).
1679
+ * so the caller can surface them.
1403
1680
  */
1404
1681
  async enhancePrompt(text) {
1405
1682
  if (!text.trim())
@@ -1497,8 +1774,12 @@ export class AgentSession {
1497
1774
  }
1498
1775
  // ── Private ────────────────────────────────────────────
1499
1776
  async createNewSession() {
1500
- const session = await this.sessionManager.create(this.cwd, this.provider, this.model);
1777
+ const session = await this.sessionManager.create(this.cwd, this.provider, this.model, {
1778
+ conversationId: this.conversationId || undefined,
1779
+ preview: this.sessionPreview || undefined,
1780
+ });
1501
1781
  this.sessionId = session.id;
1782
+ this.conversationId = session.header.conversationId ?? session.id;
1502
1783
  this.sessionPath = session.path;
1503
1784
  this.lastPersistedIndex = this.messages.length;
1504
1785
  }
@@ -1506,6 +1787,15 @@ export class AgentSession {
1506
1787
  const loaded = await this.sessionManager.load(sessionPath);
1507
1788
  // Use the leaf from the header to walk the correct branch
1508
1789
  const loadedMessages = this.sessionManager.getMessages(loaded.entries, loaded.header.leafId);
1790
+ this.conversationId = loaded.header.conversationId ?? loaded.header.id;
1791
+ const legacyLabel = [...loaded.entries]
1792
+ .reverse()
1793
+ .find((entry) => entry.type === "label")
1794
+ ?.label.replace(/\s+/g, " ")
1795
+ .trim()
1796
+ .slice(0, 80);
1797
+ this.sessionPreview =
1798
+ legacyLabel || loaded.header.preview || findUserSessionPrompt(loadedMessages);
1509
1799
  // Restore Nolan's advisory turns (custom entries, not on the message branch) so
1510
1800
  // they reappear in the transcript and survive into the continuation file.
1511
1801
  this.nolanTurns = this.sessionManager.getNolanTurns(loaded.entries);
@@ -1513,6 +1803,7 @@ export class AgentSession {
1513
1803
  this.autopilotMarkers = this.sessionManager.getAutopilotMarkers(loaded.entries);
1514
1804
  // Restore app transcript markers (plan banner / task header / errors / hints).
1515
1805
  this.appMarkers = this.sessionManager.getAppMarkers(loaded.entries);
1806
+ this.turnMetrics = this.sessionManager.getTurnMetrics(loaded.entries);
1516
1807
  // Track the current leaf for subsequent entries
1517
1808
  this.currentLeafId = loaded.header.leafId;
1518
1809
  // Rebuild messages: keep system, add loaded
@@ -1531,7 +1822,16 @@ export class AgentSession {
1531
1822
  provider: this.provider,
1532
1823
  accountId: creds.accountId,
1533
1824
  });
1534
- if (shouldCompact(this.messages, contextWindow, 0.8, undefined, getCompactionReserveTokens(this.maxTokens))) {
1825
+ const needsLoadCompaction = this.settingsManager.get("autoCompact") &&
1826
+ shouldCompact(this.messages, contextWindow, this.settingsManager.get("compactThreshold"));
1827
+ if (needsLoadCompaction && this.opts.deferLoadCompaction) {
1828
+ // Host readiness is gated on initialize() — don't block it on a summary
1829
+ // LLM call (up to 30s). runLoop()'s pre-run auto-compaction picks this
1830
+ // up on the first prompt and emits compaction_start/_end for the UI.
1831
+ log("INFO", "session", "Restored session exceeds context — deferring compaction to first prompt");
1832
+ }
1833
+ else if (needsLoadCompaction) {
1834
+ await this.subAgentManager?.hydrate(loaded.header.id);
1535
1835
  log("INFO", "session", `Restored session exceeds context — auto-compacting`);
1536
1836
  const compacted = await compact(this.messages, {
1537
1837
  provider: this.provider,
@@ -1552,9 +1852,14 @@ export class AgentSession {
1552
1852
  // what's in memory — fork a fresh session file for the compacted state
1553
1853
  // (mirrors compact()'s own persistence) so `ezcoder continue` picks up
1554
1854
  // the summary instead of the full original transcript.
1555
- const session = await this.sessionManager.create(this.cwd, this.provider, this.model);
1855
+ const session = await this.sessionManager.create(this.cwd, this.provider, this.model, {
1856
+ conversationId: this.conversationId || undefined,
1857
+ preview: this.sessionPreview || undefined,
1858
+ });
1556
1859
  this.sessionId = session.id;
1860
+ this.conversationId = session.header.conversationId ?? session.id;
1557
1861
  this.sessionPath = session.path;
1862
+ await this.subAgentManager?.rebindParentSession(this.sessionId);
1558
1863
  this.currentLeafId = null;
1559
1864
  // Re-persist (compacted) messages — skip system, it's rebuilt on load
1560
1865
  for (const msg of this.messages) {
@@ -1563,7 +1868,8 @@ export class AgentSession {
1563
1868
  await this.persistMessage(msg);
1564
1869
  }
1565
1870
  this.lastPersistedIndex = this.messages.length;
1566
- // Carry Nolan's restored turns into the continuation file.
1871
+ // Carry restored evidence and Nolan turns into the continuation file.
1872
+ await this.rePersistTurnMetrics();
1567
1873
  await this.rePersistNolanTurns();
1568
1874
  await this.rePersistAutopilotMarkers();
1569
1875
  await this.rePersistAppMarkers();
@@ -1587,6 +1893,9 @@ export class AgentSession {
1587
1893
  return this.messages;
1588
1894
  }
1589
1895
  async persistMessage(message) {
1896
+ if (!this.sessionPreview && message.role === "user") {
1897
+ this.sessionPreview = getUserSessionPrompt(message.content) ?? "";
1898
+ }
1590
1899
  // Transient sessions (subagent spawns) have no session file — skip.
1591
1900
  if (!this.sessionPath)
1592
1901
  return;