@prestyj/cli 4.10.3 → 4.12.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 (178) hide show
  1. package/README.md +4 -4
  2. package/dist/app-sidecar.d.ts +2 -0
  3. package/dist/app-sidecar.d.ts.map +1 -0
  4. package/dist/app-sidecar.js +1720 -0
  5. package/dist/app-sidecar.js.map +1 -0
  6. package/dist/cli.d.ts.map +1 -1
  7. package/dist/cli.js +10 -25
  8. package/dist/cli.js.map +1 -1
  9. package/dist/config.d.ts +5 -0
  10. package/dist/config.d.ts.map +1 -1
  11. package/dist/config.js +3 -0
  12. package/dist/config.js.map +1 -1
  13. package/dist/core/agent-session.d.ts +162 -0
  14. package/dist/core/agent-session.d.ts.map +1 -1
  15. package/dist/core/agent-session.js +544 -23
  16. package/dist/core/agent-session.js.map +1 -1
  17. package/dist/core/api-benchmark.d.ts +64 -0
  18. package/dist/core/api-benchmark.d.ts.map +1 -0
  19. package/dist/core/api-benchmark.js +381 -0
  20. package/dist/core/api-benchmark.js.map +1 -0
  21. package/dist/core/auth-providers.d.ts +18 -0
  22. package/dist/core/auth-providers.d.ts.map +1 -0
  23. package/dist/core/auth-providers.js +71 -0
  24. package/dist/core/auth-providers.js.map +1 -0
  25. package/dist/core/event-bus.d.ts +6 -0
  26. package/dist/core/event-bus.d.ts.map +1 -1
  27. package/dist/core/event-bus.js +1 -0
  28. package/dist/core/event-bus.js.map +1 -1
  29. package/dist/core/mcp/client.d.ts +21 -0
  30. package/dist/core/mcp/client.d.ts.map +1 -1
  31. package/dist/core/mcp/client.js +167 -5
  32. package/dist/core/mcp/client.js.map +1 -1
  33. package/dist/core/mcp/index.d.ts +3 -1
  34. package/dist/core/mcp/index.d.ts.map +1 -1
  35. package/dist/core/mcp/index.js +2 -0
  36. package/dist/core/mcp/index.js.map +1 -1
  37. package/dist/core/mcp/oauth-provider.d.ts +51 -0
  38. package/dist/core/mcp/oauth-provider.d.ts.map +1 -0
  39. package/dist/core/mcp/oauth-provider.js +95 -0
  40. package/dist/core/mcp/oauth-provider.js.map +1 -0
  41. package/dist/core/mcp/oauth-store.d.ts +39 -0
  42. package/dist/core/mcp/oauth-store.d.ts.map +1 -0
  43. package/dist/core/mcp/oauth-store.js +63 -0
  44. package/dist/core/mcp/oauth-store.js.map +1 -0
  45. package/dist/core/mcp/oauth-store.test.d.ts +2 -0
  46. package/dist/core/mcp/oauth-store.test.d.ts.map +1 -0
  47. package/dist/core/mcp/oauth-store.test.js +94 -0
  48. package/dist/core/mcp/oauth-store.test.js.map +1 -0
  49. package/dist/core/process-manager.d.ts.map +1 -1
  50. package/dist/core/process-manager.js +5 -1
  51. package/dist/core/process-manager.js.map +1 -1
  52. package/dist/core/project-discovery.d.ts +41 -0
  53. package/dist/core/project-discovery.d.ts.map +1 -0
  54. package/dist/core/project-discovery.js +441 -0
  55. package/dist/core/project-discovery.js.map +1 -0
  56. package/dist/core/prompt-commands.d.ts.map +1 -1
  57. package/dist/core/prompt-commands.js +203 -0
  58. package/dist/core/prompt-commands.js.map +1 -1
  59. package/dist/core/prompt-commands.test.js +39 -0
  60. package/dist/core/prompt-commands.test.js.map +1 -1
  61. package/dist/core/radio.d.ts +44 -0
  62. package/dist/core/radio.d.ts.map +1 -0
  63. package/dist/core/radio.js +250 -0
  64. package/dist/core/radio.js.map +1 -0
  65. package/dist/core/resolve-start.d.ts +29 -0
  66. package/dist/core/resolve-start.d.ts.map +1 -0
  67. package/dist/core/resolve-start.js +35 -0
  68. package/dist/core/resolve-start.js.map +1 -0
  69. package/dist/core/resolve-start.test.d.ts +2 -0
  70. package/dist/core/resolve-start.test.d.ts.map +1 -0
  71. package/dist/core/resolve-start.test.js +66 -0
  72. package/dist/core/resolve-start.test.js.map +1 -0
  73. package/dist/core/settings-manager.d.ts +5 -1
  74. package/dist/core/settings-manager.d.ts.map +1 -1
  75. package/dist/core/settings-manager.js +5 -0
  76. package/dist/core/settings-manager.js.map +1 -1
  77. package/dist/core/shell-path.d.ts +12 -0
  78. package/dist/core/shell-path.d.ts.map +1 -0
  79. package/dist/core/shell-path.js +166 -0
  80. package/dist/core/shell-path.js.map +1 -0
  81. package/dist/core/shell.d.ts +51 -0
  82. package/dist/core/shell.d.ts.map +1 -0
  83. package/dist/core/shell.js +82 -0
  84. package/dist/core/shell.js.map +1 -0
  85. package/dist/core/shell.test.d.ts +2 -0
  86. package/dist/core/shell.test.d.ts.map +1 -0
  87. package/dist/core/shell.test.js +87 -0
  88. package/dist/core/shell.test.js.map +1 -0
  89. package/dist/core/slash-commands.d.ts.map +1 -1
  90. package/dist/core/slash-commands.js +10 -0
  91. package/dist/core/slash-commands.js.map +1 -1
  92. package/dist/core/speed-benchmark.d.ts +133 -0
  93. package/dist/core/speed-benchmark.d.ts.map +1 -0
  94. package/dist/core/speed-benchmark.js +410 -0
  95. package/dist/core/speed-benchmark.js.map +1 -0
  96. package/dist/core/speed-benchmark.test.d.ts +2 -0
  97. package/dist/core/speed-benchmark.test.d.ts.map +1 -0
  98. package/dist/core/speed-benchmark.test.js +97 -0
  99. package/dist/core/speed-benchmark.test.js.map +1 -0
  100. package/dist/core/telegram-config.d.ts +21 -0
  101. package/dist/core/telegram-config.d.ts.map +1 -0
  102. package/dist/core/telegram-config.js +52 -0
  103. package/dist/core/telegram-config.js.map +1 -0
  104. package/dist/index.d.ts +1 -0
  105. package/dist/index.d.ts.map +1 -1
  106. package/dist/index.js +2 -0
  107. package/dist/index.js.map +1 -1
  108. package/dist/interactive.d.ts.map +1 -1
  109. package/dist/interactive.js +4 -3
  110. package/dist/interactive.js.map +1 -1
  111. package/dist/modes/serve-mode.d.ts +24 -0
  112. package/dist/modes/serve-mode.d.ts.map +1 -1
  113. package/dist/modes/serve-mode.js +54 -19
  114. package/dist/modes/serve-mode.js.map +1 -1
  115. package/dist/tools/bash.d.ts.map +1 -1
  116. package/dist/tools/bash.js +17 -1
  117. package/dist/tools/bash.js.map +1 -1
  118. package/dist/tools/generate-image.d.ts +39 -0
  119. package/dist/tools/generate-image.d.ts.map +1 -0
  120. package/dist/tools/generate-image.js +301 -0
  121. package/dist/tools/generate-image.js.map +1 -0
  122. package/dist/tools/generate-image.test.d.ts +2 -0
  123. package/dist/tools/generate-image.test.d.ts.map +1 -0
  124. package/dist/tools/generate-image.test.js +223 -0
  125. package/dist/tools/generate-image.test.js.map +1 -0
  126. package/dist/tools/goal-mode.test.js +1 -1
  127. package/dist/tools/goal-mode.test.js.map +1 -1
  128. package/dist/tools/goals.d.ts +7 -7
  129. package/dist/tools/index.d.ts +15 -1
  130. package/dist/tools/index.d.ts.map +1 -1
  131. package/dist/tools/index.js +17 -2
  132. package/dist/tools/index.js.map +1 -1
  133. package/dist/tools/plan-mode.test.js +5 -5
  134. package/dist/tools/plan-mode.test.js.map +1 -1
  135. package/dist/tools/prompt-hints.d.ts.map +1 -1
  136. package/dist/tools/prompt-hints.js +2 -0
  137. package/dist/tools/prompt-hints.js.map +1 -1
  138. package/dist/tools/safe-env.d.ts.map +1 -1
  139. package/dist/tools/safe-env.js +27 -0
  140. package/dist/tools/safe-env.js.map +1 -1
  141. package/dist/tools/subagent.d.ts +2 -4
  142. package/dist/tools/subagent.d.ts.map +1 -1
  143. package/dist/tools/subagent.js +24 -23
  144. package/dist/tools/subagent.js.map +1 -1
  145. package/dist/tools/tasks.d.ts +1 -7
  146. package/dist/tools/tasks.d.ts.map +1 -1
  147. package/dist/tools/tasks.js +4 -31
  148. package/dist/tools/tasks.js.map +1 -1
  149. package/dist/ui/App.d.ts +1 -1
  150. package/dist/ui/App.d.ts.map +1 -1
  151. package/dist/ui/hooks/usePixelFixFlow.d.ts +1 -1
  152. package/dist/ui/hooks/usePixelFixFlow.d.ts.map +1 -1
  153. package/dist/ui/hooks/usePixelFixFlow.js +1 -1
  154. package/dist/ui/hooks/usePixelFixFlow.js.map +1 -1
  155. package/dist/ui/hooks/useTranscriptHistory.d.ts +2 -0
  156. package/dist/ui/hooks/useTranscriptHistory.d.ts.map +1 -1
  157. package/dist/ui/hooks/useTranscriptHistory.js +3 -2
  158. package/dist/ui/hooks/useTranscriptHistory.js.map +1 -1
  159. package/dist/ui/long-prompt-regression-harness.test.js +8 -8
  160. package/dist/ui/render.d.ts +1 -1
  161. package/dist/ui/render.d.ts.map +1 -1
  162. package/dist/ui/render.js +60 -8
  163. package/dist/ui/render.js.map +1 -1
  164. package/dist/ui/startup-banner-order.test.js +1 -1
  165. package/dist/ui/startup-banner-order.test.js.map +1 -1
  166. package/dist/ui/terminal-history-repaint.test.d.ts +2 -0
  167. package/dist/ui/terminal-history-repaint.test.d.ts.map +1 -0
  168. package/dist/ui/terminal-history-repaint.test.js +73 -0
  169. package/dist/ui/terminal-history-repaint.test.js.map +1 -0
  170. package/dist/ui/terminal-history.d.ts +1 -0
  171. package/dist/ui/terminal-history.d.ts.map +1 -1
  172. package/dist/ui/terminal-history.js +31 -2
  173. package/dist/ui/terminal-history.js.map +1 -1
  174. package/dist/utils/git.d.ts +6 -0
  175. package/dist/utils/git.d.ts.map +1 -1
  176. package/dist/utils/git.js +12 -0
  177. package/dist/utils/git.js.map +1 -1
  178. package/package.json +6 -6
@@ -1,5 +1,5 @@
1
1
  import { agentLoop, isAbortError } from "@prestyj/agent";
2
- import { ProviderError } from "@prestyj/ai";
2
+ import { ProviderError, prewarmAnthropicCache, } from "@prestyj/ai";
3
3
  import { EventBus } from "./event-bus.js";
4
4
  import { SlashCommandRegistry, createBuiltinCommands, } from "./slash-commands.js";
5
5
  import { PROMPT_COMMANDS, getPromptCommand } from "./prompt-commands.js";
@@ -16,10 +16,14 @@ import { discoverSkills } from "./skills.js";
16
16
  import { ensureAppDirs } from "../config.js";
17
17
  import { buildSystemPrompt } from "../system-prompt.js";
18
18
  import { createTools, createWebSearchTool, } from "../tools/index.js";
19
- import { MCPClientManager, getMCPServers, getAllMcpServers } from "./mcp/index.js";
19
+ import { MCPClientManager, getAllMcpServers } from "./mcp/index.js";
20
20
  import { log } from "./logger.js";
21
21
  import { setEstimatorModel } from "./compaction/token-estimator.js";
22
22
  import { discoverAgents } from "./agents.js";
23
+ import { generateSessionTitle } from "../utils/session-title.js";
24
+ import { evaluateIdealReview, buildIdealReviewMessage, } from "./ideal-review.js";
25
+ import { evaluateLoopBreak, buildLoopBreakMessage, toolCallSignature, detectTextRepetition, } from "./loop-breaker.js";
26
+ import { buildRegroundingMessage } from "./regrounding.js";
23
27
  import crypto from "node:crypto";
24
28
  import fs from "node:fs/promises";
25
29
  import path from "node:path";
@@ -33,8 +37,44 @@ export class AgentSession {
33
37
  extensionLoader = new ExtensionLoader();
34
38
  messages = [];
35
39
  tools = [];
40
+ /** Rebuilds the read tool for a new model (video byte cap is baked in at
41
+ * creation). Called from switchModel so video-capable models get the
42
+ * read-tool's native-video path after a mid-session model change. */
43
+ rebuildReadTool;
36
44
  skills = [];
37
45
  cacheKeyLogged = false;
46
+ // ── Self-correction hook state (mirrors the TUI's useAgentLoop refs) ──
47
+ // Reset at the start of every run; observed from the event stream; read by
48
+ // the loop-break (mid-loop) and ideal-review (pre-stop) callbacks.
49
+ hookStats = {
50
+ changedLines: 0,
51
+ toolCalls: 0,
52
+ toolFailures: 0,
53
+ turns: 0,
54
+ writeCalls: 0,
55
+ editCalls: 0,
56
+ bashCalls: 0,
57
+ };
58
+ hookText = "";
59
+ hookConsecutiveFailures = 0;
60
+ hookMaxSignatureRepeats = 0;
61
+ hookMaxSameFileEdits = 0;
62
+ hookSignatureCounts = new Map();
63
+ hookFileEditCounts = new Map();
64
+ hookToolCalls = new Map();
65
+ idealReviewInjected = false;
66
+ loopBreakInjected = false;
67
+ regroundingInjected = false;
68
+ compactionOccurred = false;
69
+ originalRequest = "";
70
+ /** True after the cache has been pre-warmed for this session. Ensures we only
71
+ * fire the warm-up call once (before the first real turn). */
72
+ cachePrewarmed = false;
73
+ // Messages queued by the user while a run is in flight. Drained at the
74
+ // mid-loop steering boundary (user steering wins over the hooks), mirroring
75
+ // the TUI's getSteeringMessages. Each entry carries its own attachments so a
76
+ // user can queue media (images/video/files) mid-run, not just plain text.
77
+ userQueue = [];
38
78
  processManager;
39
79
  lspManager;
40
80
  mcpManager;
@@ -45,6 +85,12 @@ export class AgentSession {
45
85
  maxTokens;
46
86
  thinkingLevel;
47
87
  customSystemPrompt;
88
+ /** Shared with the tool layer so plan-mode restrictions read live state. */
89
+ planModeRef = { current: false };
90
+ /** Path of the approved plan currently being implemented, or undefined. When
91
+ * set, the system prompt carries the `[DONE:n]` progress contract so the
92
+ * model emits step-completion markers the UI's plan-progress widget reads. */
93
+ approvedPlanPath;
48
94
  sessionId = "";
49
95
  sessionPath = "";
50
96
  lastPersistedIndex = 0;
@@ -57,10 +103,27 @@ export class AgentSession {
57
103
  this.model = options.model;
58
104
  this.cwd = options.cwd;
59
105
  this.baseUrl = options.baseUrl;
60
- this.maxTokens = options.maxTokens ?? getModel(options.model)?.maxOutputTokens ?? 16384;
106
+ this.maxTokens = this.resolveMaxTokens(options.model);
61
107
  this.thinkingLevel = options.thinkingLevel;
62
108
  this.customSystemPrompt = options.systemPrompt;
63
109
  }
110
+ /**
111
+ * Derive the output-token cap for a model. Follows the active model's
112
+ * `maxOutputTokens` so a session booted on a large-output model (e.g. Kimi's
113
+ * 256K) doesn't carry that cap to a smaller one (e.g. Opus's 128K) after a
114
+ * model switch — that mismatch surfaces from the provider as
115
+ * `max_tokens: 262144 > 128000, which is the maximum allowed …`. An explicit
116
+ * `maxTokens` override is honored but clamped to the model's ceiling.
117
+ */
118
+ resolveMaxTokens(modelId) {
119
+ const modelInfo = getModel(modelId);
120
+ if (this.opts.maxTokens) {
121
+ return modelInfo
122
+ ? Math.min(this.opts.maxTokens, modelInfo.maxOutputTokens)
123
+ : this.opts.maxTokens;
124
+ }
125
+ return modelInfo?.maxOutputTokens ?? 16384;
126
+ }
64
127
  async initialize() {
65
128
  // Set model for accurate token estimation
66
129
  setEstimatorModel(this.model);
@@ -87,17 +150,30 @@ export class AgentSession {
87
150
  globalAgentsDir: paths.agentsDir,
88
151
  projectDir: this.cwd,
89
152
  });
90
- const { tools, processManager, lspManager } = createTools(this.cwd, {
153
+ const { tools, processManager, rebuildReadTool, lspManager } = await createTools(this.cwd, {
91
154
  agents,
92
155
  skills: this.skills,
93
156
  provider: this.provider,
94
157
  model: this.model,
95
158
  lspDiagnostics: this.settingsManager.get("lspDiagnostics"),
96
- // Lazy — sessionId isn't assigned yet when createTools() runs, so we
97
- // must defer reading the cache key until the sub-agent actually fires.
159
+ authStorage: this.authStorage,
160
+ // Lazy sessionId/model/provider can change after createTools() runs, so
161
+ // sub-agent spawns read the current parent state at execution time.
162
+ getProvider: () => this.provider,
163
+ getModel: () => this.model,
98
164
  getCacheKey: () => this.getPromptCacheKey(),
165
+ // Plan mode: only wired when the host supplies callbacks. The ref is
166
+ // shared so bash/edit/write enforce read-only restrictions live.
167
+ ...(this.opts.onEnterPlan || this.opts.onExitPlan
168
+ ? {
169
+ planModeRef: this.planModeRef,
170
+ onEnterPlan: this.opts.onEnterPlan,
171
+ onExitPlan: this.opts.onExitPlan,
172
+ }
173
+ : {}),
99
174
  });
100
175
  this.tools = tools;
176
+ this.rebuildReadTool = rebuildReadTool;
101
177
  this.processManager = processManager;
102
178
  this.lspManager = lspManager;
103
179
  // Connect MCP servers (non-blocking — failures are logged and skipped).
@@ -108,23 +184,20 @@ export class AgentSession {
108
184
  // skip user servers: a single `/expand` fans out 5 parallel subagents, and
109
185
  // cold-spawning every user MCP server (e.g. npx-based remotes) per
110
186
  // short-lived child would be slow and could hammer rate-limited endpoints.
187
+ //
188
+ // The connect attempt itself can block for up to the per-server connect
189
+ // timeout (~30s) — a slow stdio server such as a first-run
190
+ // `npx -y @playwright/mcp` download stalls here. When the host gates its own
191
+ // readiness on initialize() (the ezcoder-app sidecar can't emit its
192
+ // listening handshake until this resolves), `backgroundMcpConnect` moves the
193
+ // connect off the critical path so the session becomes usable immediately
194
+ // and tools are appended whenever the servers come up.
111
195
  this.mcpManager = new MCPClientManager();
112
- try {
113
- let apiKey;
114
- if (this.provider === "glm") {
115
- try {
116
- const glmCreds = await this.authStorage.resolveCredentials("glm");
117
- apiKey = glmCreds.accessToken;
118
- }
119
- catch {
120
- // GLM not configured — skip Z.AI MCP servers
121
- }
122
- }
123
- const mcpTools = await this.mcpManager.connectAll(getMCPServers(this.provider, apiKey));
124
- this.tools.push(...mcpTools);
196
+ if (this.opts.backgroundMcpConnect) {
197
+ void this.connectMcpServers();
125
198
  }
126
- catch (err) {
127
- log("WARN", "mcp", `MCP initialization failed: ${err instanceof Error ? err.message : String(err)}`);
199
+ else {
200
+ await this.connectMcpServers();
128
201
  }
129
202
  const basePrompt = this.customSystemPrompt ??
130
203
  (await buildSystemPrompt(this.cwd, this.skills, false, undefined, this.tools.map((tool) => tool.name), undefined, this.provider));
@@ -194,6 +267,47 @@ export class AgentSession {
194
267
  await this.extensionLoader.loadAll(paths.extensionsDir, extContext);
195
268
  this.eventBus.emit("session_start", { sessionId: this.sessionId });
196
269
  }
270
+ /**
271
+ * Connect all configured MCP servers and append their tools to `this.tools`.
272
+ * Resolves the GLM api key first (Z.AI's bundled servers need it). Never
273
+ * throws — a failed connect is logged and skipped — so it is safe to either
274
+ * `await` (CLI: tools ready before the first turn) or fire-and-forget
275
+ * (sidecar: `backgroundMcpConnect`, so a slow stdio server can't stall
276
+ * startup). Tools are pushed onto the live array the agent loop reads each
277
+ * turn, so background-connected servers become available on the next prompt.
278
+ */
279
+ async connectMcpServers() {
280
+ if (!this.mcpManager)
281
+ return;
282
+ try {
283
+ let apiKey;
284
+ if (this.provider === "glm") {
285
+ try {
286
+ const glmCreds = await this.authStorage.resolveCredentials("glm");
287
+ apiKey = glmCreds.accessToken;
288
+ }
289
+ catch {
290
+ // GLM not configured — skip Z.AI MCP servers
291
+ }
292
+ }
293
+ const mcpTools = await this.mcpManager.connectAll(await getAllMcpServers(this.provider, apiKey, this.cwd));
294
+ this.tools.push(...mcpTools);
295
+ // Background connect resolves AFTER initialize() has already built the
296
+ // system prompt (the default path awaits this before buildSystemPrompt,
297
+ // so its prompt already lists the tools). Refresh messages[0] so the
298
+ // model is also told about the MCP tools by name on its next turn —
299
+ // mirrors the TUI's replaceSystemPrompt after connectInitialMcpTools.
300
+ // Safe ordering: this method's first await yields before initialize()
301
+ // sets `messages`, and connectAll (process spawn / network) always
302
+ // resolves long after the local-only remainder of init has finished.
303
+ if (this.opts.backgroundMcpConnect && mcpTools.length > 0) {
304
+ await this.rebuildSystemPromptInPlace();
305
+ }
306
+ }
307
+ catch (err) {
308
+ log("WARN", "mcp", `MCP initialization failed: ${err instanceof Error ? err.message : String(err)}`);
309
+ }
310
+ }
197
311
  /**
198
312
  * Process user input. Handles slash commands or runs agent loop.
199
313
  */
@@ -235,6 +349,225 @@ export class AgentSession {
235
349
  this.lastPersistedIndex = this.messages.length;
236
350
  await this.runLoop();
237
351
  }
352
+ /**
353
+ * Prompt with multimodal attachments (images / videos) alongside optional
354
+ * text. Images and videos become native content blocks the model can see;
355
+ * non-media files are surfaced as a text note with their saved path so the
356
+ * agent can open them with its tools. Slash-command parsing is skipped —
357
+ * attachments are always a direct conversational turn.
358
+ */
359
+ async promptWithAttachments(text, attachments) {
360
+ const parts = this.buildAttachmentParts(text, attachments);
361
+ if (parts.length === 0)
362
+ return;
363
+ const userMessage = { role: "user", content: parts };
364
+ this.messages.push(userMessage);
365
+ await this.persistMessage(userMessage);
366
+ this.lastPersistedIndex = this.messages.length;
367
+ await this.runLoop();
368
+ }
369
+ /**
370
+ * Build the native content blocks (text + image/video notes + file notes) for
371
+ * a user message with attachments. Shared by {@link promptWithAttachments} and
372
+ * the mid-run steering drain so queued media is delivered identically.
373
+ */
374
+ buildAttachmentParts(text, attachments) {
375
+ const parts = [];
376
+ const fileNotes = [];
377
+ const modelSupportsVideo = getModel(this.model)?.supportsVideo ?? false;
378
+ for (const a of attachments) {
379
+ if (a.kind === "image") {
380
+ parts.push({ type: "image", mediaType: a.mediaType, data: a.data });
381
+ if (a.path) {
382
+ parts.push({ type: "text", text: `[Image saved at ${a.path}]` });
383
+ }
384
+ }
385
+ else if (a.kind === "video") {
386
+ // Mirror the CLI's buildUserContentWithAttachments: never send inline
387
+ // VideoContent in the user message. Video-capable models (Kimi/Gemini/
388
+ // MiniMax) watch video via the read tool, which auto-compresses to the
389
+ // model's byte cap and delivers it in the provider's required shape.
390
+ // Non-video models get a plain note so they know to use ffmpeg. The file
391
+ // was already saved to disk by prepareAttachments in the sidecar.
392
+ if (modelSupportsVideo && a.path) {
393
+ parts.push({
394
+ type: "text",
395
+ text: `The user attached a video at ${a.path}. You CAN watch it: call the read tool ` +
396
+ `on this exact path now, then answer based on what you see. Do not say you ` +
397
+ `cannot watch video — reading the file lets you analyze it.`,
398
+ });
399
+ }
400
+ else if (a.path) {
401
+ parts.push({
402
+ type: "text",
403
+ text: `[User attached a video file at ${a.path}. You cannot watch video directly; ` +
404
+ `if needed, use ffmpeg to extract frames or audio.]`,
405
+ });
406
+ }
407
+ else {
408
+ parts.push({
409
+ type: "text",
410
+ text: `[User attached a video file but it could not be saved for analysis.]`,
411
+ });
412
+ }
413
+ }
414
+ else if (a.path) {
415
+ fileNotes.push(`- ${a.name} (saved at ${a.path})`);
416
+ }
417
+ }
418
+ const textParts = [];
419
+ if (text.trim())
420
+ textParts.push(text.trim());
421
+ if (fileNotes.length > 0) {
422
+ textParts.push(`Attached files (inspect with your tools):\n${fileNotes.join("\n")}`);
423
+ }
424
+ if (textParts.length > 0)
425
+ parts.unshift({ type: "text", text: textParts.join("\n\n") });
426
+ return parts;
427
+ }
428
+ /**
429
+ * Reset per-run self-correction hook state. Mirrors the TUI's run_start
430
+ * resets so each run evaluates the hooks from a clean slate. `originalRequest`
431
+ * is the verbatim user ask, pinned for post-compaction re-grounding.
432
+ */
433
+ resetHookState(originalRequest) {
434
+ this.hookStats = {
435
+ changedLines: 0,
436
+ toolCalls: 0,
437
+ toolFailures: 0,
438
+ turns: 0,
439
+ writeCalls: 0,
440
+ editCalls: 0,
441
+ bashCalls: 0,
442
+ };
443
+ this.hookText = "";
444
+ this.hookConsecutiveFailures = 0;
445
+ this.hookMaxSignatureRepeats = 0;
446
+ this.hookMaxSameFileEdits = 0;
447
+ this.hookSignatureCounts.clear();
448
+ this.hookFileEditCounts.clear();
449
+ this.hookToolCalls.clear();
450
+ this.idealReviewInjected = false;
451
+ this.loopBreakInjected = false;
452
+ this.regroundingInjected = false;
453
+ this.compactionOccurred = false;
454
+ this.originalRequest = originalRequest;
455
+ }
456
+ /**
457
+ * Fold one agent event into the hook stat accumulators. Pure bookkeeping —
458
+ * the same signals the TUI's useAgentLoop collects, so the loop-break and
459
+ * ideal-review decisions match across the CLI and the app.
460
+ */
461
+ trackHookEvent(event) {
462
+ switch (event.type) {
463
+ case "text_delta":
464
+ this.hookText += event.text;
465
+ break;
466
+ case "tool_call_start":
467
+ this.hookToolCalls.set(event.toolCallId, { name: event.name, args: event.args ?? {} });
468
+ break;
469
+ case "tool_call_end": {
470
+ const call = this.hookToolCalls.get(event.toolCallId);
471
+ const name = call?.name ?? "";
472
+ const args = call?.args;
473
+ this.hookStats.toolCalls += 1;
474
+ if (event.isError)
475
+ this.hookStats.toolFailures += 1;
476
+ if (name === "write")
477
+ this.hookStats.writeCalls += 1;
478
+ if (name === "edit")
479
+ this.hookStats.editCalls += 1;
480
+ if (name === "bash")
481
+ this.hookStats.bashCalls += 1;
482
+ this.hookConsecutiveFailures = event.isError ? this.hookConsecutiveFailures + 1 : 0;
483
+ const sig = toolCallSignature(name, args);
484
+ const sigNext = (this.hookSignatureCounts.get(sig) ?? 0) + 1;
485
+ this.hookSignatureCounts.set(sig, sigNext);
486
+ if (sigNext > this.hookMaxSignatureRepeats)
487
+ this.hookMaxSignatureRepeats = sigNext;
488
+ if ((name === "edit" || name === "write") && args) {
489
+ const filePath = args.file_path;
490
+ if (typeof filePath === "string") {
491
+ const fileNext = (this.hookFileEditCounts.get(filePath) ?? 0) + 1;
492
+ this.hookFileEditCounts.set(filePath, fileNext);
493
+ if (fileNext > this.hookMaxSameFileEdits)
494
+ this.hookMaxSameFileEdits = fileNext;
495
+ }
496
+ }
497
+ if (name === "edit" && !event.isError) {
498
+ const diff = event.details?.diff ?? event.result;
499
+ const added = (diff.match(/^\+[^+]/gm) ?? []).length;
500
+ const removed = (diff.match(/^-[^-]/gm) ?? []).length;
501
+ this.hookStats.changedLines += added + removed;
502
+ }
503
+ break;
504
+ }
505
+ case "turn_end":
506
+ this.hookStats.turns = event.turn;
507
+ break;
508
+ }
509
+ }
510
+ /**
511
+ * Mid-loop steering hook: fires the loop-breaker when the agent looks stuck,
512
+ * then post-compaction re-grounding. At most one of each per run. Mirrors the
513
+ * TUI's getSteeringMessages ordering (minus user steering, which the app
514
+ * delivers as normal prompts).
515
+ */
516
+ getHookSteeringMessages() {
517
+ // User steering wins: drain any messages queued during this run first so the
518
+ // agent sees them mid-loop instead of after it stops.
519
+ if (this.userQueue.length > 0) {
520
+ const queued = this.userQueue.splice(0);
521
+ // Plain-text-only queue: keep the simple merged-string message.
522
+ if (queued.every((m) => m.attachments.length === 0)) {
523
+ const merged = queued.map((m) => m.text).join("\n\n");
524
+ return [{ role: "user", content: merged }];
525
+ }
526
+ // Any queued attachments → deliver one user message with text + media
527
+ // blocks built the same way as a non-queued attachment prompt.
528
+ const parts = [];
529
+ for (const m of queued)
530
+ parts.push(...this.buildAttachmentParts(m.text, m.attachments));
531
+ return [{ role: "user", content: parts }];
532
+ }
533
+ if (!this.settingsManager.get("idealReviewEnabled"))
534
+ return null;
535
+ if (!this.loopBreakInjected) {
536
+ const decision = evaluateLoopBreak({
537
+ consecutiveFailures: this.hookConsecutiveFailures,
538
+ maxSignatureRepeats: this.hookMaxSignatureRepeats,
539
+ maxSameFileEdits: this.hookMaxSameFileEdits,
540
+ textRepetitionDetected: detectTextRepetition(this.hookText),
541
+ });
542
+ if (decision.shouldBreak) {
543
+ this.loopBreakInjected = true;
544
+ this.eventBus.emit("hook", { kind: "loop_break" });
545
+ return [buildLoopBreakMessage(decision.reasons)];
546
+ }
547
+ }
548
+ if (!this.regroundingInjected && this.compactionOccurred) {
549
+ this.regroundingInjected = true;
550
+ this.eventBus.emit("hook", { kind: "regrounding" });
551
+ return [buildRegroundingMessage(this.originalRequest)];
552
+ }
553
+ return null;
554
+ }
555
+ /**
556
+ * Pre-stop follow-up hook: runs the ideal review once, when the agent would
557
+ * otherwise finish and the change set is substantial enough to warrant it.
558
+ */
559
+ getHookFollowUpMessages() {
560
+ if (!this.settingsManager.get("idealReviewEnabled"))
561
+ return null;
562
+ if (this.idealReviewInjected)
563
+ return null;
564
+ const decision = evaluateIdealReview(this.hookStats);
565
+ if (!decision.shouldReview)
566
+ return null;
567
+ this.idealReviewInjected = true;
568
+ this.eventBus.emit("hook", { kind: "ideal" });
569
+ return [buildIdealReviewMessage(decision.reasons)];
570
+ }
238
571
  /** Auto-compact if needed, run agent loop with auth retry, and persist messages. */
239
572
  async runLoop() {
240
573
  // One-shot cache-key marker per session so turn_end cacheRead numbers
@@ -249,6 +582,11 @@ export class AgentSession {
249
582
  transient: String(!!this.opts.transient),
250
583
  });
251
584
  }
585
+ // Reset self-correction hook state for this run; pin the latest user message
586
+ // as the verbatim original request for post-compaction re-grounding.
587
+ const lastUser = [...this.messages].reverse().find((m) => m.role === "user");
588
+ const originalRequest = typeof lastUser?.content === "string" ? lastUser.content : "";
589
+ this.resetHookState(originalRequest);
252
590
  // Resolve OAuth credentials and run agent loop.
253
591
  // On 401, force-refresh the token and retry once — the provider may have
254
592
  // revoked the token server-side before the stored expiry (e.g. after a restart).
@@ -264,6 +602,8 @@ export class AgentSession {
264
602
  const threshold = this.settingsManager.get("compactThreshold");
265
603
  if (shouldCompact(this.messages, contextWindow, threshold)) {
266
604
  await this.compact(creds);
605
+ // Re-grounding hook keys off this — the context was just summarized.
606
+ this.compactionOccurred = true;
267
607
  }
268
608
  }
269
609
  const userAgent = this.provider === "anthropic" ? await getClaudeCliUserAgent() : undefined;
@@ -288,7 +628,9 @@ export class AgentSession {
288
628
  defaultHeaders: this.provider === "moonshot" && isKimiCodingEndpoint(effectiveBaseUrl)
289
629
  ? kimiCodingHeaders()
290
630
  : undefined,
291
- cacheRetention: "short",
631
+ // speedProfile "optimized": 1-h cache TTL (survives turns >5 min apart)
632
+ // + pre-warm before the first turn. "baseline": current 5-min default.
633
+ cacheRetention: this.isSpeedOptimized() ? "long" : "short",
292
634
  promptCacheKey: this.getPromptCacheKey(),
293
635
  supportsImages: modelInfo?.supportsImages,
294
636
  supportsVideo: modelInfo?.supportsVideo,
@@ -296,12 +638,22 @@ export class AgentSession {
296
638
  // clearToolUses disabled — causes model to output unsolicited context summaries
297
639
  // Single tool result shouldn't exceed 30% of context window (in chars)
298
640
  maxToolResultChars: Math.floor(getContextWindow(this.model, { provider: this.provider, accountId }) * 3.5 * 0.3),
641
+ // Self-correction hooks (same as the TUI): loop-break + re-grounding are
642
+ // polled mid-loop; the ideal review is polled when the agent would stop.
643
+ getSteeringMessages: () => this.getHookSteeringMessages(),
644
+ getFollowUpMessages: () => this.getHookFollowUpMessages(),
299
645
  });
300
646
  for await (const event of generator) {
647
+ this.trackHookEvent(event);
301
648
  this.eventBus.forwardAgentEvent(event);
302
649
  }
303
650
  };
304
651
  try {
652
+ // Fire cache pre-warm before the first turn (Anthropic + speedProfile optimized).
653
+ // Runs concurrently with nothing — it must complete before runAgentLoop so
654
+ // the cache is warm when the real request arrives. Best-effort: swallowed
655
+ // inside maybePrewarmCache/prewarmAnthropicCache.
656
+ await this.maybePrewarmCache(creds);
305
657
  await runAgentLoop(creds.accessToken, creds.accountId, creds.projectId);
306
658
  }
307
659
  catch (err) {
@@ -341,7 +693,25 @@ export class AgentSession {
341
693
  this.provider = provider;
342
694
  this.model = model;
343
695
  setEstimatorModel(model);
344
- this.eventBus.emit("model_change", { provider: this.provider, model: this.model });
696
+ // maxTokens must follow the active model — it was frozen at the boot
697
+ // model's `maxOutputTokens` in the constructor, so without this a session
698
+ // booted on e.g. Kimi (256K) keeps sending that cap after switching to a
699
+ // smaller model (Opus 128K), which the provider rejects.
700
+ this.maxTokens = this.resolveMaxTokens(model);
701
+ this.eventBus.emit("model_change", {
702
+ provider: this.provider,
703
+ model: this.model,
704
+ supportsVideo: getModel(this.model)?.supportsVideo ?? false,
705
+ });
706
+ // Rebuild the read tool for the new model's video byte cap. The tool's
707
+ // video capability (description + native-video execute path) is baked in
708
+ // at creation from the model's maxVideoBytes, so switching to/from a
709
+ // video-capable model mid-session needs a fresh tool object — mirrors
710
+ // the TUI's rebuildReadTool call on model switch.
711
+ if (this.rebuildReadTool) {
712
+ const newReadTool = this.rebuildReadTool(model);
713
+ this.tools = this.tools.map((t) => (t.name === "read" ? newReadTool : t));
714
+ }
345
715
  // Update provider-specific tools when provider changes
346
716
  if (provider && provider !== prevProvider) {
347
717
  // Add/remove client-side web_search tool based on provider.
@@ -425,6 +795,9 @@ export class AgentSession {
425
795
  });
426
796
  }
427
797
  async newSession() {
798
+ // A fresh session drops any in-flight plan state so its prompt is clean.
799
+ this.planModeRef.current = false;
800
+ this.approvedPlanPath = undefined;
428
801
  const basePrompt = this.customSystemPrompt ??
429
802
  (await buildSystemPrompt(this.cwd, this.skills, false, undefined, this.tools.map((tool) => tool.name), undefined, this.provider));
430
803
  this.messages = [{ role: "system", content: basePrompt }];
@@ -485,15 +858,163 @@ export class AgentSession {
485
858
  sessionId: this.sessionId,
486
859
  sessionPath: this.sessionPath,
487
860
  messageCount: this.messages.length,
861
+ planMode: this.planModeRef.current,
488
862
  };
489
863
  }
864
+ getPlanMode() {
865
+ return this.planModeRef.current;
866
+ }
867
+ /** Queue a user message (optionally with attachments) to be injected mid-run
868
+ * as steering. Returns the new queue length. No-op semantics are the caller's
869
+ * concern. */
870
+ queueMessage(text, attachments = []) {
871
+ this.userQueue.push({ text, attachments });
872
+ return this.userQueue.length;
873
+ }
874
+ /** Number of messages currently queued. */
875
+ getQueuedCount() {
876
+ return this.userQueue.length;
877
+ }
878
+ /** Clear the queue, returning the combined text (to restore to the composer).
879
+ * Queued attachments are dropped on cancel — the composer only restores text. */
880
+ drainQueue() {
881
+ return this.userQueue
882
+ .splice(0)
883
+ .map((m) => m.text)
884
+ .join("\n\n");
885
+ }
886
+ /** Snapshot of background processes (bash run_in_background), newest-state. */
887
+ listBackgroundProcesses() {
888
+ return this.processManager?.list() ?? [];
889
+ }
890
+ /** Stop a background process by id. Returns a human-readable status string. */
891
+ async killBackgroundProcess(id) {
892
+ if (!this.processManager)
893
+ return `No background process with id "${id}"`;
894
+ return this.processManager.stop(id);
895
+ }
896
+ /**
897
+ * Toggle plan mode: flips the shared ref (so tools enforce read-only
898
+ * restrictions) and rebuilds the system prompt in place so the model is told
899
+ * about the mode change on its next turn. No-op when a custom system prompt
900
+ * is in force (the host owns the prompt then).
901
+ */
902
+ async setPlanMode(active) {
903
+ this.planModeRef.current = active;
904
+ // Entering plan mode discards any prior approved-plan contract (a new plan
905
+ // is about to be drafted); exiting keeps it (set explicitly via accept).
906
+ if (active)
907
+ this.approvedPlanPath = undefined;
908
+ await this.rebuildSystemPromptInPlace();
909
+ }
910
+ /**
911
+ * Bake an approved plan into the system prompt so the model is told to emit
912
+ * `[DONE:n]` markers as it completes each step (the contract the UI's
913
+ * plan-progress widget reads). Pass `undefined` to clear it. No-op when a
914
+ * custom system prompt is in force (the host owns the prompt then).
915
+ */
916
+ async setApprovedPlan(approvedPlanPath) {
917
+ this.approvedPlanPath = approvedPlanPath;
918
+ await this.rebuildSystemPromptInPlace();
919
+ }
920
+ /** Rebuild messages[0] from current plan-mode + approved-plan state. */
921
+ async rebuildSystemPromptInPlace() {
922
+ if (this.customSystemPrompt)
923
+ return;
924
+ const rebuilt = await buildSystemPrompt(this.cwd, this.skills, this.planModeRef.current, this.approvedPlanPath, this.tools.map((tool) => tool.name), undefined, this.provider);
925
+ if (this.messages[0]?.role === "system") {
926
+ this.messages[0] = { role: "system", content: rebuilt };
927
+ }
928
+ else {
929
+ this.messages.unshift({ role: "system", content: rebuilt });
930
+ }
931
+ }
490
932
  getMessages() {
491
933
  return this.messages;
492
934
  }
935
+ /**
936
+ * Generate a short LLM session title from the conversation so far (first user
937
+ * message + first assistant reply). Best-effort; returns null on failure or
938
+ * when there's no user message yet. Uses the cheapest model for the provider.
939
+ */
940
+ async generateTitle() {
941
+ const extractText = (content) => typeof content === "string"
942
+ ? content
943
+ : content
944
+ .map((c) => c.type === "text" && "text" in c && typeof c.text === "string" ? c.text : "")
945
+ .join(" ");
946
+ const userMsg = this.messages.find((m) => m.role === "user");
947
+ const assistantMsg = this.messages.find((m) => m.role === "assistant");
948
+ const userText = userMsg ? extractText(userMsg.content) : "";
949
+ if (!userText.trim())
950
+ return null;
951
+ try {
952
+ const creds = await this.authStorage.resolveCredentials(this.provider);
953
+ const title = await generateSessionTitle({
954
+ provider: this.provider,
955
+ userMessage: userText,
956
+ assistantPreview: assistantMsg ? extractText(assistantMsg.content).slice(0, 200) : "",
957
+ apiKey: creds.accessToken,
958
+ baseUrl: this.baseUrl ?? creds.baseUrl,
959
+ accountId: creds.accountId,
960
+ });
961
+ return title || null;
962
+ }
963
+ catch {
964
+ return null;
965
+ }
966
+ }
967
+ /** Current reasoning/thinking level, or undefined when thinking is off. */
968
+ getThinkingLevel() {
969
+ return this.thinkingLevel;
970
+ }
971
+ /** Set the reasoning/thinking level (undefined turns thinking off). Takes
972
+ * effect on the next prompt, since the in-flight loop reads it at start. */
973
+ setThinkingLevel(level) {
974
+ this.thinkingLevel = level;
975
+ }
493
976
  /** Replace the abort signal (e.g. after cancellation). */
494
977
  setSignal(signal) {
495
978
  this.opts = { ...this.opts, signal };
496
979
  }
980
+ /** True when speedProfile is "optimized" (1-h cache TTL + pre-warm). */
981
+ isSpeedOptimized() {
982
+ return this.settingsManager?.get("speedProfile") === "optimized";
983
+ }
984
+ /** Fire a cache pre-warm request for Anthropic so the first real turn is a
985
+ * cache read instead of a cold write. No-op for other providers and when
986
+ * speedProfile is not "optimized". Entirely best-effort — any failure is
987
+ * swallowed so prewarm never blocks or aborts the real prompt. */
988
+ async maybePrewarmCache(creds) {
989
+ if (this.cachePrewarmed || !this.isSpeedOptimized() || this.provider !== "anthropic") {
990
+ return;
991
+ }
992
+ this.cachePrewarmed = true;
993
+ try {
994
+ const userAgent = await getClaudeCliUserAgent();
995
+ const systemText = typeof this.messages[0]?.content === "string" ? this.messages[0].content : "";
996
+ if (!systemText)
997
+ return;
998
+ await prewarmAnthropicCache({
999
+ apiKey: creds.accessToken,
1000
+ model: this.model,
1001
+ system: systemText,
1002
+ tools: this.tools.map((t) => ({
1003
+ name: t.name,
1004
+ description: t.description,
1005
+ parameters: t.parameters,
1006
+ ...(t.rawInputSchema ? { rawInputSchema: t.rawInputSchema } : {}),
1007
+ })),
1008
+ baseUrl: this.baseUrl ?? creds.baseUrl,
1009
+ userAgent,
1010
+ cacheRetention: "long",
1011
+ signal: this.opts.signal,
1012
+ });
1013
+ }
1014
+ catch {
1015
+ // Best-effort — prewarm failure must never block the session.
1016
+ }
1017
+ }
497
1018
  getPromptCacheKey() {
498
1019
  if (this.opts.promptCacheKey)
499
1020
  return this.opts.promptCacheKey;