@yanzc/claude-pro 0.0.0-stage → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/.env.example +41 -0
  2. package/LICENSE +21 -0
  3. package/README.md +314 -3
  4. package/bin/claude-pro.js +32 -0
  5. package/package.json +40 -6
  6. package/src/agent.js +752 -0
  7. package/src/agents.js +186 -0
  8. package/src/api.js +529 -0
  9. package/src/cache.js +150 -0
  10. package/src/checkpoints.js +184 -0
  11. package/src/claude-code.js +60 -0
  12. package/src/codex/client.js +307 -0
  13. package/src/codex/translate-request.js +239 -0
  14. package/src/codex/translate-stream.js +328 -0
  15. package/src/commands.js +839 -0
  16. package/src/config.js +453 -0
  17. package/src/context.js +109 -0
  18. package/src/filestate.js +57 -0
  19. package/src/gateway.js +143 -0
  20. package/src/hooks.js +81 -0
  21. package/src/main.js +193 -0
  22. package/src/mcp.js +290 -0
  23. package/src/modes.js +154 -0
  24. package/src/oauth.js +287 -0
  25. package/src/peers.js +157 -0
  26. package/src/print.js +106 -0
  27. package/src/prompts.js +179 -0
  28. package/src/ratelimit.js +244 -0
  29. package/src/restart.js +115 -0
  30. package/src/runtime.js +162 -0
  31. package/src/selfcheck.js +71 -0
  32. package/src/session.js +139 -0
  33. package/src/supervisor.js +110 -0
  34. package/src/tasks.js +76 -0
  35. package/src/tools/bash.js +227 -0
  36. package/src/tools/effort.js +41 -0
  37. package/src/tools/files.js +352 -0
  38. package/src/tools/index.js +52 -0
  39. package/src/tools/interaction.js +157 -0
  40. package/src/tools/notebook.js +91 -0
  41. package/src/tools/peers.js +45 -0
  42. package/src/tools/restart.js +50 -0
  43. package/src/tools/search.js +458 -0
  44. package/src/tools/skill.js +32 -0
  45. package/src/tools/task.js +139 -0
  46. package/src/tools/web.js +283 -0
  47. package/src/ui/ansi.js +228 -0
  48. package/src/ui/dialogs.js +122 -0
  49. package/src/ui/editor.js +389 -0
  50. package/src/ui/markdown.js +249 -0
  51. package/src/ui/render.js +155 -0
  52. package/src/ui/repl.js +1460 -0
  53. package/src/ui/screen.js +259 -0
  54. package/src/updater.js +93 -0
  55. package/src/utils/clipboard.js +197 -0
  56. package/src/utils/diff.js +95 -0
  57. package/src/utils/paths.js +37 -0
  58. package/src/utils/shell.js +251 -0
package/src/agent.js ADDED
@@ -0,0 +1,752 @@
1
+ // The agent loop: send the conversation, stream the reply, run requested tools,
2
+ // feed results back, repeat until the model stops asking for tools.
3
+
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { streamMessage, contextTokens, isAbort } from './api.js';
7
+ import { compactPrompt, continuationMessage, PLAN_MODE_REMINDER, SUMMARY_SYSTEM } from './prompts.js';
8
+ import { toolResultsDir } from './session.js';
9
+ import { rebuildFileState } from './filestate.js';
10
+ import { CacheStats } from './cache.js';
11
+ import { resolvePath } from './utils/paths.js';
12
+
13
+ export const INTERRUPT_MESSAGE = '[Request interrupted by user]';
14
+ export const INTERRUPT_TOOL_MESSAGE = '[Request interrupted by user for tool use]';
15
+
16
+ const abortError = () => Object.assign(new Error('aborted'), { name: 'AbortError' });
17
+
18
+ // Automatic compactions that may fail in a row before it is left off for the conversation (until a /compact
19
+ // succeeds or /clear): one that cannot succeed would otherwise cost its requests again on every prompt.
20
+ const MAX_COMPACT_FAILURES = 3;
21
+
22
+ const IMAGE_TOKENS = 1600; // roughly what a 1568px image costs; its base64 is not text
23
+
24
+ export function estimateTokens(value) {
25
+ let images = 0;
26
+ const s = typeof value === 'string' ? value : JSON.stringify(value, (k, v) => {
27
+ if (v && typeof v === 'object' && v.type === 'image') { images++; return undefined; }
28
+ return v;
29
+ });
30
+ let ascii = 0, other = 0;
31
+ for (let i = 0; i < (s || '').length; i++) (s.charCodeAt(i) < 128 ? ascii++ : other++);
32
+ return Math.ceil(ascii / 4 + other * 0.8) + images * IMAGE_TOKENS;
33
+ }
34
+
35
+ const PREVIEW_CHARS = 2000;
36
+
37
+ // Save a tool's output to a file of the project's tool-results directory. Returns its path, or null.
38
+ function saveToolOutput(root, id, text) {
39
+ try {
40
+ const dir = toolResultsDir(root);
41
+ fs.mkdirSync(dir, { recursive: true });
42
+ const file = path.join(dir, `${String(id).replace(/[^\w-]/g, '_')}.txt`);
43
+ fs.writeFileSync(file, text);
44
+ return file;
45
+ } catch { return null; }
46
+ }
47
+
48
+ // A tool result too large to send in full is saved to a file; the model gets a preview and the
49
+ // path, and can page through the file with Read/Grep. Returns the text to send instead.
50
+ export function persistLargeOutput(root, id, text) {
51
+ const file = saveToolOutput(root, id, text);
52
+ const kb = (text.length / 1024).toFixed(1);
53
+ const preview = text.slice(0, PREVIEW_CHARS);
54
+ if (!file) return `${text.slice(0, 20_000)}\n\n[Output truncated: ${kb}KB total; it could not be saved to a file.]`;
55
+ return `<persisted-output>\nOutput too large (${kb}KB). Full output saved to: ${file}\nUse Read with offset/limit, or Grep, on that file to see what you need.\n\nPreview (first ${PREVIEW_CHARS / 1000}KB):\n${preview}\n...\n</persisted-output>`;
56
+ }
57
+
58
+ // Coming back after a long pause, the prompt cache has expired and the next request writes the whole conversation
59
+ // again anyway. Claude Code then clears the results of all but the last few tool calls, and so does this program,
60
+ // with the same numbers: more than 65 minutes away, the last 5 results kept, at least 20k tokens to gain. Only
61
+ // what the model can get again is cleared (what a file, a command or a search showed); a sub-agent's report, the
62
+ // user's answers and the like stay whatever their age.
63
+ export const IDLE_CLEARING = { idleMs: 65 * 60_000, keep: 5, atLeast: 20_000 };
64
+ export const CLEARED = '[Old tool result content cleared]';
65
+ const CLEARABLE_TOOLS = new Set(['Read', 'Bash', 'BashOutput', 'Grep', 'Glob', 'WebFetch', 'WebSearch', 'Edit', 'Write']);
66
+ const MIN_CLEAR_TOKENS = 100; // a smaller result is left as it is: clearing it gains next to nothing
67
+
68
+ const isThinking = (b) => b.type === 'thinking' || b.type === 'redacted_thinking';
69
+ const resultText = (content) => (typeof content === 'string' ? content : Array.isArray(content) ? content.filter((b) => b.type === 'text').map((b) => b.text).join('\n') : '');
70
+
71
+ // Clear the results of all but the last `keep` clearable tool calls, if that takes out at least `atLeast` tokens
72
+ // (else null). The thinking goes too, all of it: once a tool result before it has changed, the API takes earlier
73
+ // thinking for tampered with, and rejects the request. `save(id, text)` keeps a cleared output in a file and
74
+ // returns its path, so that what a command printed can still be looked up; a file read is read again instead.
75
+ // Returns {messages (a new list: the old one is not changed), results (how many were cleared), tokens (about
76
+ // how many went, thinking included), forget (the files whose Read results went, and of which no Read is left)}.
77
+ export function clearOldToolResults(messages, { keep = IDLE_CLEARING.keep, atLeast = IDLE_CLEARING.atLeast, save = () => null, cwd = process.cwd() } = {}) {
78
+ const uses = new Map(); // tool_use id -> the tool_use block
79
+ for (const m of messages) if (m.role === 'assistant' && Array.isArray(m.content)) for (const b of m.content) if (b.type === 'tool_use') uses.set(b.id, b);
80
+ const results = messages.flatMap((m) => (m.role === 'user' && Array.isArray(m.content) ? m.content : []))
81
+ .filter((b) => b.type === 'tool_result' && CLEARABLE_TOOLS.has(uses.get(b.tool_use_id)?.name));
82
+ const clear = new Set();
83
+ let tokens = 0;
84
+ for (const b of results.slice(0, Math.max(0, results.length - keep))) {
85
+ const text = resultText(b.content);
86
+ if (text.startsWith(CLEARED) || text.startsWith('<persisted-output>')) continue; // cleared already, or short already
87
+ const t = estimateTokens(b.content);
88
+ if (t < MIN_CLEAR_TOKENS) continue;
89
+ clear.add(b);
90
+ tokens += t;
91
+ }
92
+ if (tokens < atLeast) return null;
93
+ const fileOf = (b) => {
94
+ const p = uses.get(b.tool_use_id)?.input?.file_path;
95
+ return typeof p === 'string' && p.trim() ? resolvePath(p, cwd) : null;
96
+ };
97
+ const placeholder = (b) => {
98
+ if (uses.get(b.tool_use_id).name === 'Read') return `${CLEARED} Read the file again if you need it.`;
99
+ const text = resultText(b.content);
100
+ const file = text && save(b.tool_use_id, text);
101
+ return file ? `${CLEARED} The full output was saved to ${file}; read it there if you need it.` : CLEARED;
102
+ };
103
+ const gone = new Set();
104
+ const shown = new Set();
105
+ const out = messages.map((m) => {
106
+ if (!Array.isArray(m.content)) return m;
107
+ if (m.role === 'assistant') {
108
+ if (!m.content.some(isThinking)) return m;
109
+ for (const b of m.content) if (isThinking(b)) tokens += estimateTokens(b.thinking ?? b.data ?? '');
110
+ const content = m.content.filter((b) => !isThinking(b));
111
+ return { ...m, content: content.length ? content : [{ type: 'text', text: '(no content)' }] };
112
+ }
113
+ for (const b of m.content) {
114
+ if (b.type !== 'tool_result' || uses.get(b.tool_use_id)?.name !== 'Read' || !fileOf(b)) continue;
115
+ if (clear.has(b)) gone.add(fileOf(b));
116
+ else if (!b.is_error && !resultText(b.content).startsWith(CLEARED)) shown.add(fileOf(b));
117
+ }
118
+ if (!m.content.some((b) => clear.has(b))) return m;
119
+ return { ...m, content: m.content.map((b) => (clear.has(b) ? { ...b, content: placeholder(b) } : b)) };
120
+ });
121
+ return { messages: out, results: clear.size, tokens, forget: [...gone].filter((f) => !shown.has(f)) };
122
+ }
123
+
124
+ const notificationBlocks = (notes) => notes.map((n) => ({ type: 'text', text: n.text }));
125
+
126
+ // Queued input may arrive as plain strings or as ready-made content blocks (text + images).
127
+ const toQueuedBlocks = (items) => items.map((q) => (typeof q === 'string' ? { type: 'text', text: q } : q));
128
+
129
+ function toBlocks(content) {
130
+ return typeof content === 'string' ? [{ type: 'text', text: content }] : content;
131
+ }
132
+
133
+ // Merge consecutive same-role messages and drop empties so the API always sees a valid sequence.
134
+ export function normalizeMessages(messages) {
135
+ const out = [];
136
+ for (const m of messages) {
137
+ const blocks = toBlocks(m.content).filter((b) => !(b.type === 'text' && !b.text));
138
+ if (!blocks.length) continue;
139
+ const prev = out[out.length - 1];
140
+ if (prev && prev.role === m.role) prev.content = [...prev.content, ...blocks];
141
+ else out.push({ role: m.role, content: blocks });
142
+ }
143
+ // tool_result blocks must come first in a user message.
144
+ for (const m of out) {
145
+ if (m.role === 'user' && m.content.some((b) => b.type === 'tool_result')) {
146
+ m.content = [...m.content.filter((b) => b.type === 'tool_result'), ...m.content.filter((b) => b.type !== 'tool_result')];
147
+ }
148
+ }
149
+ while (out.length && out[0].role !== 'user') out.shift();
150
+ return out;
151
+ }
152
+
153
+ // Minimal JSON-schema check for required fields and primitive types.
154
+ function validateInput(schema, input) {
155
+ if (!schema || typeof input !== 'object' || input === null) return 'Input must be an object';
156
+ for (const key of schema.required || []) {
157
+ if (input[key] === undefined || input[key] === null) return `The required parameter \`${key}\` is missing`;
158
+ }
159
+ for (const [key, spec] of Object.entries(schema.properties || {})) {
160
+ const v = input[key];
161
+ if (v === undefined || v === null || !spec.type) continue;
162
+ const t = Array.isArray(v) ? 'array' : typeof v;
163
+ const ok = spec.type === t || (spec.type === 'integer' && t === 'number') || (spec.type === 'number' && t === 'string' && !isNaN(+v));
164
+ if (!ok) return `The parameter \`${key}\` type is expected as \`${spec.type}\` but provided as \`${t}\``;
165
+ if (spec.type === 'number' && t === 'string') input[key] = +v;
166
+ }
167
+ return null;
168
+ }
169
+
170
+ export class Agent {
171
+ constructor({ config, modes, tools, systemPrompt, io, model, isSubagent = false, cwd, name = 'main', spawnSubagent, hooks, tasks, checkpoints, depth = 0, refreshSystemPrompt, beforeTurn }) {
172
+ this.config = config;
173
+ this.depth = depth; // 0 for the main agent, +1 for every level of sub-agent
174
+ this.hooks = hooks;
175
+ this.tasks = tasks; // background tasks (sub-agents started with run_in_background)
176
+ this.notifications = []; // {taskId, text} waiting to be shown to the model
177
+ this.editsThisRun = 0; // successful file edits since the last verification
178
+ this.modes = modes; // session mode: full access, or read-only plan mode
179
+ this.checkpoints = checkpoints; // snapshots of files before they are edited, per user prompt
180
+ this.tools = tools;
181
+ this.systemPrompt = systemPrompt; // string or () => string
182
+ this.io = io;
183
+ this.modelFn = typeof model === 'function' ? model : () => model;
184
+ this.isSubagent = isSubagent;
185
+ this.name = name;
186
+ this.spawnSubagent = spawnSubagent;
187
+ this.messages = [];
188
+ this.todos = [];
189
+ this.fileState = new Map();
190
+ this.projectRoot = cwd;
191
+ this.cwd = cwd;
192
+ this.lastContextTokens = 0;
193
+ this.tokensSinceUsage = 0;
194
+ // What this agent's own requests used. `requests` counts the turns' requests; the summary requests of
195
+ // compactions are counted apart (`compactions`) and their tokens are in the totals like any others.
196
+ this.usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cacheWrite1h: 0, requests: 0, compactions: 0 };
197
+ // What the sub-agents this agent started used (they bill like everything else); only the main agent has any.
198
+ this.subUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cacheWrite1h: 0, requests: 0, compactions: 0, runs: 0 };
199
+ this.cache = new CacheStats(); // how much of the input came from the prompt cache, and why a request missed
200
+ this.refreshSystemPrompt = refreshSystemPrompt; // called when the conversation starts over from a summary
201
+ this.beforeTurn = beforeTurn; // called at the start of each of the main agent's turns (the date note, see runtime.js)
202
+ this.recordedSystem = null; // the system prompt last reported to the io (see callModel)
203
+ this.reminders = []; // texts for the next user message, as <system-reminder>s (the main agent's only)
204
+ this.toolUseCount = 0;
205
+ this.postCompactTokens = undefined; // undefined: never compacted; null: compacted, awaiting measurement
206
+ this.compactFailures = 0; // automatic compactions that failed in a row
207
+ this.lastActivityAt = 0; // when the last message was added (ms since epoch; 0: unknown), see clearAfterIdle
208
+ }
209
+
210
+ get model() { return this.modelFn(); }
211
+ get toolMap() { return new Map(this.tools.map((t) => [t.name, t])); }
212
+
213
+ toolSchemas() {
214
+ return this.tools.map((t) => ({ name: t.name, description: typeof t.description === 'function' ? t.description() : t.description, input_schema: t.input_schema }));
215
+ }
216
+
217
+ get contextEstimate() { return this.lastContextTokens + this.tokensSinceUsage; }
218
+
219
+ pushMessage(msg) {
220
+ this.messages.push(msg);
221
+ this.lastActivityAt = Date.now();
222
+ if (msg.role === 'user') this.tokensSinceUsage += estimateTokens(msg.content);
223
+ this.io.messageAdded?.(msg, this);
224
+ }
225
+
226
+ replaceMessages(messages) {
227
+ this.messages = messages;
228
+ this.cache.noteRewrite(); // the old conversation is no longer a prefix of the next request: not a cache miss
229
+ this.io.messagesReplaced?.(messages, this);
230
+ }
231
+
232
+ // The system prompt for the next request: this agent's own, built when it is first needed.
233
+ currentSystem() {
234
+ return typeof this.systemPrompt === 'function' ? this.systemPrompt() : this.systemPrompt;
235
+ }
236
+
237
+ // A note for the model that goes with the next user message (as a <system-reminder>), after the part of the
238
+ // request that is already cached.
239
+ addReminder(text) {
240
+ if (text) this.reminders.push(text);
241
+ }
242
+
243
+ // Everything this agent and the sub-agents it started have used so far.
244
+ totalUsage() {
245
+ const a = this.usage;
246
+ const b = this.subUsage;
247
+ const sum = (k) => a[k] + b[k];
248
+ return {
249
+ input: sum('input'), output: sum('output'), cacheRead: sum('cacheRead'), cacheWrite: sum('cacheWrite'), cacheWrite1h: sum('cacheWrite1h'),
250
+ requests: sum('requests'), compactions: sum('compactions'),
251
+ };
252
+ }
253
+
254
+ // Count a finished sub-agent's usage as this agent's (whatever happened to it, it was billed).
255
+ addSubUsage(u) {
256
+ const b = this.subUsage;
257
+ for (const k of ['input', 'output', 'cacheRead', 'cacheWrite', 'cacheWrite1h', 'requests', 'compactions']) b[k] += u[k] || 0;
258
+ b.runs++;
259
+ }
260
+
261
+ // Count one finished request. `kind` is "turn" for the conversation's own requests and "summary" for the
262
+ // request that compacts it; both go into the prompt cache statistics.
263
+ recordUsage(res, kind = 'turn') {
264
+ const u = res.usage || {};
265
+ this.usage.input += u.input_tokens || 0;
266
+ this.usage.output += u.output_tokens || 0;
267
+ this.usage.cacheRead += u.cache_read_input_tokens || 0;
268
+ this.usage.cacheWrite += u.cache_creation_input_tokens || 0;
269
+ this.usage.cacheWrite1h += u.cache_creation?.ephemeral_1h_input_tokens || 0;
270
+ if (kind === 'summary') this.usage.compactions++;
271
+ else this.usage.requests++;
272
+ this.cache.record({ usage: u, ...res.cache });
273
+ this.io.usage?.(u, this);
274
+ }
275
+
276
+ // After resuming a conversation: drop what the previous one had read and rebuild the map from the
277
+ // restored transcript, so files the model remembers reading can be edited without a re-read.
278
+ restoreFileState(messages, times) {
279
+ this.fileState.clear();
280
+ for (const [file, state] of rebuildFileState(messages, times, this.cwd)) this.fileState.set(file, state);
281
+ }
282
+
283
+ // The text the assistant has written so far (newest last, clipped to `limit` characters). A
284
+ // sub-agent that fails part-way hands this back so its work is not lost with the error.
285
+ assistantText(limit = 6000) {
286
+ const text = this.messages
287
+ .filter((m) => m.role === 'assistant' && Array.isArray(m.content))
288
+ .flatMap((m) => m.content.filter((b) => b.type === 'text').map((b) => b.text.trim()))
289
+ .filter(Boolean)
290
+ .join('\n\n');
291
+ return text.length > limit ? `…${text.slice(-limit)}` : text;
292
+ }
293
+
294
+ // Take pending background-task notifications as content blocks.
295
+ drainNotifications() {
296
+ return notificationBlocks(this.notifications.splice(0));
297
+ }
298
+
299
+ // Start over with an empty conversation, to move to another session (/clear, /resume). The
300
+ // session being left is deliberately not told (no messagesReplaced): an "empty conversation" record
301
+ // appended to it would make it load as empty, and it would vanish from the list of conversations
302
+ // that can be resumed.
303
+ resetConversation() {
304
+ this.messages = [];
305
+ this.todos = [];
306
+ this.fileState.clear();
307
+ this.lastContextTokens = 0;
308
+ this.tokensSinceUsage = 0;
309
+ this.postCompactTokens = undefined;
310
+ this.cache.newConversation();
311
+ this.reminders = [];
312
+ this.compactFailures = 0;
313
+ this.lastActivityAt = 0;
314
+ this.recordedSystem = null; // the next conversation's session file has to get the system prompt too, even if it is the same text
315
+ this.checkpoints?.reset();
316
+ }
317
+
318
+ // Back after more than an hour away (IDLE_CLEARING): clear old tool results, see clearOldToolResults. The main
319
+ // conversation only, where Config.clearAfterIdle has it on. A turn that stopped in the middle of its tool calls
320
+ // (the request after a tool result failed or was interrupted) goes on from there. With manual thinking
321
+ // (MAX_THINKING_TOKENS: thinking.type "enabled") the API wants its last assistant message to start with its
322
+ // thinking, which cannot stay once a result before it has changed: then nothing is cleared. Adaptive thinking,
323
+ // the default and the only mode of the 5.x models, has no such rule: all thinking may go, that turn's too.
324
+ // Returns what clearOldToolResults did, or null.
325
+ clearAfterIdle(now = Date.now()) {
326
+ if (this.isSubagent || !this.config.clearAfterIdle || !this.lastActivityAt) return null;
327
+ const idle = now - this.lastActivityAt;
328
+ if (idle <= IDLE_CLEARING.idleMs) return null;
329
+ const last = this.messages.findLast((m) => m.role === 'assistant');
330
+ const unfinished = Array.isArray(last?.content) && last.content.some((b) => b.type === 'tool_use');
331
+ if (unfinished && this.config.thinkingBudget > 0) return null;
332
+ const out = clearOldToolResults(this.messages, { save: (id, text) => saveToolOutput(this.projectRoot, id, text), cwd: this.cwd });
333
+ if (!out) return null;
334
+ this.replaceMessages(out.messages); // into the session file too: a resumed conversation goes on with the short one
335
+ for (const file of out.forget) this.fileState.delete(file); // the model no longer sees it: Read before Edit/Write
336
+ this.lastContextTokens = Math.max(0, this.lastContextTokens - out.tokens);
337
+ this.io.notice?.(`Back after ${Math.round(idle / 60_000)} minutes, with the prompt cache expired: cleared ${out.results} old tool results and the earlier thinking (about ${Math.round(out.tokens / 1000)}k tokens). The last ${IDLE_CLEARING.keep} results are kept; files are read again when needed.`);
338
+ return out;
339
+ }
340
+
341
+ // A new request from the user goes back to the effort the user chose, after the Effort tool changed it for
342
+ // the one before. A request that a restart continues keeps it.
343
+ endEffortBoost() {
344
+ const cfg = this.config;
345
+ if (cfg.effortBoostKeepNext) { cfg.effortBoostKeepNext = false; return; }
346
+ if (!cfg.effortBoost) return;
347
+ const was = cfg.effortBoost;
348
+ cfg.effortBoost = null;
349
+ this.io.notice?.(`Effort back to ${cfg.effort || 'the model default'} (it was raised to ${was} for the previous request).`);
350
+ }
351
+
352
+ // Run one user turn to completion. Returns {text, interrupted}.
353
+ async run(userContent, { signal } = {}) {
354
+ if (!this.isSubagent) {
355
+ this.beforeTurn?.();
356
+ this.endEffortBoost();
357
+ }
358
+ this.clearAfterIdle();
359
+ let content = toBlocks(userContent);
360
+ if (this.modes.mode === 'plan' && !this.isSubagent) content = [...content, { type: 'text', text: PLAN_MODE_REMINDER }];
361
+ if (this.hooks?.has('UserPromptSubmit') && !this.isSubagent) {
362
+ const prompt = content.filter((b) => b.type === 'text').map((b) => b.text).join('\n');
363
+ const h = await this.hooks.run('UserPromptSubmit', { prompt }, { cwd: this.cwd, signal });
364
+ for (const w of h.warnings) this.io.notice?.(w, 'warn');
365
+ if (h.blocked) {
366
+ this.io.notice?.(`Prompt blocked by hook: ${h.message}`, 'warn');
367
+ return { text: '', interrupted: false, rejected: true };
368
+ }
369
+ if (h.context.length) content = [...content, { type: 'text', text: `<system-reminder>${h.context.join('\n')}</system-reminder>` }];
370
+ }
371
+ if (!this.isSubagent) content = [...content, ...this.drainNotifications()];
372
+ if (this.reminders.length) content = [...content, ...this.reminders.splice(0).map((text) => ({ type: 'text', text: `<system-reminder>${text}</system-reminder>` }))];
373
+ if (!this.isSubagent && this.checkpoints) {
374
+ const prompt = content.find((b) => b.type === 'text' && !b.text.startsWith('<system-reminder>') && !b.text.startsWith('<task-notification>'))?.text;
375
+ this.checkpoints.beginTurn({ label: prompt || '(background agent report)', messageIndex: this.messages.length });
376
+ }
377
+ this.pushMessage({ role: 'user', content });
378
+
379
+ this.editsThisRun = 0;
380
+ let verifyRounds = 0;
381
+ let stopHookRounds = 0;
382
+ let continuations = 0;
383
+ let compactedForError = false;
384
+ let finalText = '';
385
+ for (;;) {
386
+ if (signal?.aborted) return this.handleInterrupt(null);
387
+ if (this.shouldAutoCompact()) {
388
+ const outcome = await this.compactOnTheWay(signal);
389
+ if (outcome?.interrupted) return outcome;
390
+ if (outcome?.failed) compactedForError = true; // it has just failed: a too-long prompt in this turn is not tried again
391
+ }
392
+
393
+ let res;
394
+ try {
395
+ res = await this.callModel(signal);
396
+ } catch (err) {
397
+ if (isAbort(err) || signal?.aborted) {
398
+ // Keep whatever text the user already saw so the model knows what it said.
399
+ if (err.partial?.length) this.pushMessage({ role: 'assistant', content: err.partial });
400
+ return this.handleInterrupt(null);
401
+ }
402
+ if (err.promptTooLong && !compactedForError && this.messages.length > 2) {
403
+ compactedForError = true;
404
+ this.io.notice?.('Context limit reached; compacting conversation…', 'warn');
405
+ const outcome = await this.compactOnTheWay(signal, { required: true });
406
+ if (outcome?.interrupted) return outcome;
407
+ continue;
408
+ }
409
+ throw err;
410
+ }
411
+ const { message, stopReason } = res;
412
+ if (!message.content.length) message.content.push({ type: 'text', text: '(no content)' });
413
+ this.pushMessage(message);
414
+ this.io.assistantMessage?.(message, this);
415
+
416
+ const toolUses = message.content.filter((b) => b.type === 'tool_use');
417
+ const text = message.content.filter((b) => b.type === 'text').map((b) => b.text).join('\n').trim();
418
+ if (text) finalText = text;
419
+
420
+ if (!toolUses.length) {
421
+ if (stopReason === 'max_tokens' && continuations < 3) {
422
+ continuations++;
423
+ this.io.notice?.('Response hit the output token limit; asking the model to continue…', 'warn');
424
+ this.pushMessage({ role: 'user', content: [{ type: 'text', text: '<system-reminder>Your previous response was cut off because it reached the maximum output length. Continue exactly where you left off, without repeating what you already wrote.</system-reminder>' }] });
425
+ continue;
426
+ }
427
+ const queued = [...toQueuedBlocks(this.io.takeQueued?.() || []), ...(this.isSubagent ? [] : this.drainNotifications())];
428
+ if (queued.length) {
429
+ this.pushMessage({ role: 'user', content: queued });
430
+ continue;
431
+ }
432
+ const verify = this.config.verifyCommand;
433
+ if (verify && !this.isSubagent && this.editsThisRun > 0) {
434
+ this.editsThisRun = 0;
435
+ const v = await this.runVerify(verify, signal);
436
+ if (signal?.aborted) return this.handleInterrupt(null);
437
+ if (!v.ok) {
438
+ if (verifyRounds < this.config.verifyMaxRounds) {
439
+ verifyRounds++;
440
+ this.pushMessage({ role: 'user', content: [{ type: 'text', text: `<system-reminder>You edited files, so the verification command \`${verify}\` was run automatically, and it failed (attempt ${verifyRounds} of ${this.config.verifyMaxRounds}). Fix the cause and do not stop until it passes; if the failure is unrelated to your change or cannot be fixed, explain why.\n\n${v.output}</system-reminder>` }] });
441
+ continue;
442
+ }
443
+ this.io.notice?.(`Verification still failing after ${verifyRounds} fix attempts: ${verify}`, 'warn');
444
+ }
445
+ }
446
+ if (this.hooks?.has('Stop') && !this.isSubagent && stopHookRounds < 3) {
447
+ const h = await this.hooks.run('Stop', { stop_reason: stopReason }, { cwd: this.cwd, signal });
448
+ for (const w of h.warnings) this.io.notice?.(w, 'warn');
449
+ if (h.blocked) {
450
+ stopHookRounds++;
451
+ this.pushMessage({ role: 'user', content: [{ type: 'text', text: `<system-reminder>A Stop hook asked you to keep working:\n${h.message}</system-reminder>` }] });
452
+ continue;
453
+ }
454
+ }
455
+ return { text: finalText, interrupted: false };
456
+ }
457
+
458
+ const outcome = await this.runTools(toolUses, signal);
459
+ const resultContent = [...outcome.results];
460
+ const queued = this.io.takeQueued?.() || [];
461
+ resultContent.push(...toQueuedBlocks(queued));
462
+ if (!this.isSubagent) resultContent.push(...this.drainNotifications());
463
+ if (outcome.interrupted) {
464
+ resultContent.push({ type: 'text', text: INTERRUPT_TOOL_MESSAGE });
465
+ this.pushMessage({ role: 'user', content: resultContent });
466
+ return { text: finalText, interrupted: true };
467
+ }
468
+ this.pushMessage({ role: 'user', content: resultContent });
469
+ if (outcome.stop) return { text: finalText, interrupted: false, rejected: true };
470
+ }
471
+ }
472
+
473
+ shouldAutoCompact() {
474
+ if (!this.config.autoCompactEnabled || this.messages.length <= 2) return false;
475
+ if (this.compactFailures >= MAX_COMPACT_FAILURES) return false;
476
+ const threshold = this.config.autoCompactThreshold(this.model);
477
+ if (this.contextEstimate < threshold) return false;
478
+ // Right after a compaction the base context (system prompt, tools, summary) may itself be
479
+ // near a very small window; require real growth so we don't compact on every step.
480
+ if (this.postCompactTokens != null && this.contextEstimate - this.postCompactTokens < threshold * 0.1) return false;
481
+ return true;
482
+ }
483
+
484
+ handleInterrupt() {
485
+ this.pushMessage({ role: 'user', content: [{ type: 'text', text: INTERRUPT_MESSAGE }] });
486
+ return { text: '', interrupted: true };
487
+ }
488
+
489
+ // Compact in the middle of a turn. The user can interrupt this like any other request (Esc during a
490
+ // pause for a rate limit, say), and that ends the turn as an interruption rather than an error: the
491
+ // result to return from run() is given back, or null when the compaction went through.
492
+ // A compaction that fails does not fail the turn when the request still fits (the threshold leaves room
493
+ // below the model's limit): the turn goes on without it, and {failed: true} is returned. After
494
+ // MAX_COMPACT_FAILURES in a row it is left off (see shouldAutoCompact). `required`: the request is already
495
+ // too long, so there is nothing to go on with, and the error is thrown.
496
+ async compactOnTheWay(signal, { required = false } = {}) {
497
+ try {
498
+ await this.compact({ signal, auto: true });
499
+ return null;
500
+ } catch (err) {
501
+ if (isAbort(err) || signal?.aborted) return this.handleInterrupt();
502
+ this.compactFailures++;
503
+ if (required) throw err;
504
+ this.io.notice?.(this.compactFailures >= MAX_COMPACT_FAILURES
505
+ ? `Auto-compaction failed ${this.compactFailures} times in a row (${err.message}). It is off for this conversation until /compact succeeds or /clear.`
506
+ : `Auto-compaction failed (${err.message}); continuing without it.`, 'warn');
507
+ return { failed: true };
508
+ }
509
+ }
510
+
511
+ // Run the verification command in the project root with the Bash tool. Returns {ok, output}.
512
+ async runVerify(command, signal) {
513
+ const bash = this.tools.find((t) => t.name === 'Bash');
514
+ if (!bash) return { ok: true, output: '' };
515
+ const tool = { name: 'Verify', summarize: () => command };
516
+ const input = { command, timeout: this.config.bashMaxTimeout };
517
+ const handle = this.io.toolStart?.({ id: `verify_${Date.now()}`, name: 'Verify', input }, tool, input, this) || {};
518
+ const ctx = { ...this.makeCtx(signal), cwd: this.projectRoot, setCwd() {} };
519
+ ctx.onProgress = (p) => handle.progress?.(p);
520
+ let result;
521
+ try {
522
+ result = await bash.call(input, ctx);
523
+ } catch (err) {
524
+ handle.done?.({ isError: true, content: 'Interrupted', interrupted: true });
525
+ if (isAbort(err) || signal?.aborted) return { ok: true, output: '' };
526
+ return { ok: false, output: `Error: ${err.message}` };
527
+ }
528
+ const output = typeof result.content === 'string' ? result.content : '';
529
+ const ok = !result.isError;
530
+ handle.done?.({ ...result, display: { summary: ok ? 'Verification passed' : undefined } });
531
+ return { ok, output: output.length > 15_000 ? `${output.slice(0, 3000)}\n...\n${output.slice(-12_000)}` : output };
532
+ }
533
+
534
+ async callModel(signal) {
535
+ const system = this.currentSystem();
536
+ // The system prompt is kept with the conversation, so that a resumed one can continue behind the same
537
+ // prompt (and so with the cache it has) instead of a freshly built one.
538
+ if (!this.isSubagent && system !== this.recordedSystem) {
539
+ this.recordedSystem = system;
540
+ this.io.systemPrompt?.(system, this);
541
+ }
542
+ const res = await streamMessage(this.config, {
543
+ model: this.model,
544
+ system,
545
+ messages: normalizeMessages(this.messages),
546
+ tools: this.toolSchemas(),
547
+ cacheBucket: this.isSubagent ? 'other' : 'main',
548
+ }, { signal, onEvent: (ev) => this.io.streamEvent?.(ev, this) });
549
+ this.lastContextTokens = contextTokens(res.usage);
550
+ this.tokensSinceUsage = 0;
551
+ if (this.postCompactTokens === null) this.postCompactTokens = this.lastContextTokens;
552
+ this.recordUsage(res);
553
+ return res;
554
+ }
555
+
556
+ makeCtx(signal, onProgress) {
557
+ const agent = this;
558
+ return {
559
+ get cwd() { return agent.cwd; },
560
+ setCwd(dir) { agent.cwd = dir; },
561
+ projectRoot: this.projectRoot,
562
+ config: this.config,
563
+ signal,
564
+ fileState: this.fileState,
565
+ modes: this.modes,
566
+ checkpoints: this.checkpoints,
567
+ agent: this,
568
+ io: this.io,
569
+ onProgress,
570
+ spawnSubagent: this.spawnSubagent,
571
+ tasks: this.tasks,
572
+ };
573
+ }
574
+
575
+ // Consecutive read-only tools run concurrently; everything else runs in order.
576
+ async runTools(toolUses, signal) {
577
+ const map = this.toolMap;
578
+ const results = new Map();
579
+ let stop = false;
580
+ let interrupted = false;
581
+ const batches = [];
582
+ for (const tu of toolUses) {
583
+ const tool = map.get(tu.name);
584
+ const safe = !!tool?.readOnly && tool.name !== 'ExitPlanMode' && tool.name !== 'AskUserQuestion';
585
+ const last = batches[batches.length - 1];
586
+ if (safe && last?.parallel) last.items.push(tu);
587
+ else batches.push({ parallel: safe, items: [tu] });
588
+ }
589
+ this.io.toolBatchStart?.(toolUses, this);
590
+ for (const batch of batches) {
591
+ if (stop || interrupted) break;
592
+ const run = async (tu) => {
593
+ try {
594
+ const r = await this.runOne(tu, signal, map);
595
+ results.set(tu.id, r.block);
596
+ if (r.stop) stop = true;
597
+ } catch (err) {
598
+ if (isAbort(err) || signal?.aborted) {
599
+ interrupted = true;
600
+ results.set(tu.id, { type: 'tool_result', tool_use_id: tu.id, is_error: true, content: 'Interrupted by user' });
601
+ } else {
602
+ results.set(tu.id, { type: 'tool_result', tool_use_id: tu.id, is_error: true, content: `Error: ${err.message}` });
603
+ }
604
+ }
605
+ };
606
+ if (batch.parallel) await Promise.all(batch.items.map(run));
607
+ else for (const tu of batch.items) { if (stop || interrupted) break; await run(tu); }
608
+ if (signal?.aborted) interrupted = true;
609
+ }
610
+ const blocks = toolUses.map((tu) => results.get(tu.id) || {
611
+ type: 'tool_result', tool_use_id: tu.id, is_error: true,
612
+ content: interrupted ? 'Interrupted by user' : 'Skipped: the user rejected an earlier tool call in this batch.',
613
+ });
614
+ return { results: blocks, stop, interrupted };
615
+ }
616
+
617
+ async runOne(tu, signal, map) {
618
+ const fail = (content, extra = {}) => ({ block: { type: 'tool_result', tool_use_id: tu.id, is_error: true, content }, ...extra });
619
+ const tool = map.get(tu.name);
620
+ if (!tool) return fail(`Error: No such tool available: ${tu.name}`);
621
+ const input = tu.input || {};
622
+ if (input.__invalid_json !== undefined) {
623
+ return fail('Error: the tool input was not valid JSON (it may have been truncated by the output token limit). Try again with a smaller input, e.g. write large files in several steps.');
624
+ }
625
+ const schemaErr = validateInput(tool.input_schema, input);
626
+ if (schemaErr) return fail(`InputValidationError: ${tool.name} failed due to the following issue:\n${schemaErr}`);
627
+
628
+ const ctx = this.makeCtx(signal);
629
+ const invalid = tool.validate?.(input, ctx);
630
+ if (invalid) {
631
+ this.io.toolRejected?.(tu, tool, invalid, this);
632
+ return fail(invalid);
633
+ }
634
+
635
+ // Nothing is ever asked: tools run right away. The only gate is plan mode (read-only research).
636
+ const decision = this.modes.check(tool, input);
637
+ if (decision.behavior === 'deny') {
638
+ this.io.toolRejected?.(tu, tool, decision.message, this);
639
+ return fail(decision.message);
640
+ }
641
+
642
+ if (this.hooks?.has('PreToolUse')) {
643
+ const h = await this.hooks.run('PreToolUse', { tool_name: tool.name, tool_input: input }, { cwd: this.cwd, signal });
644
+ for (const w of h.warnings) this.io.notice?.(w, 'warn');
645
+ if (signal?.aborted) throw abortError();
646
+ if (h.blocked) {
647
+ this.io.toolRejected?.(tu, tool, h.message, this);
648
+ return fail(`Blocked by PreToolUse hook: ${h.message}`);
649
+ }
650
+ }
651
+
652
+ // Remember what the file looked like before this edit, so /rewind can put it back.
653
+ if (tool.editsFiles && this.checkpoints) {
654
+ try { const file = tool.filePath?.(input, ctx); if (file) this.checkpoints.track(file); } catch { /* never block an edit on bookkeeping */ }
655
+ }
656
+
657
+ this.toolUseCount++;
658
+ const handle = this.io.toolStart?.(tu, tool, input, this) || {};
659
+ ctx.onProgress = (p) => handle.progress?.(p);
660
+ let result;
661
+ try {
662
+ result = await tool.call(input, ctx);
663
+ } catch (err) {
664
+ if (isAbort(err) || signal?.aborted) {
665
+ handle.done?.({ isError: true, content: 'Interrupted', interrupted: true, display: err.partialOutput ? { output: err.partialOutput } : undefined });
666
+ throw abortError();
667
+ }
668
+ result = { isError: true, content: `Error: ${err.message}` };
669
+ }
670
+ handle.done?.(result);
671
+ let content = typeof result.content === 'string' ? (result.content || '(no output)') : result.content;
672
+ if (this.hooks?.has('PostToolUse')) {
673
+ const h = await this.hooks.run('PostToolUse', { tool_name: tool.name, tool_input: input, tool_response: typeof content === 'string' ? content.slice(0, 20_000) : '[non-text result]' }, { cwd: this.cwd, signal });
674
+ for (const w of h.warnings) this.io.notice?.(w, 'warn');
675
+ const extra = [...(h.blocked ? [h.message] : []), ...h.context].join('\n');
676
+ if (extra && typeof content === 'string') content += `\n\n<system-reminder>PostToolUse hook:\n${extra}</system-reminder>`;
677
+ }
678
+ if (typeof content === 'string' && tool.name !== 'Read' && content.length > this.config.maxToolResultChars) {
679
+ content = persistLargeOutput(this.projectRoot, tu.id, content);
680
+ }
681
+ if (tool.editsFiles && !result.isError) this.editsThisRun++;
682
+ return {
683
+ block: { type: 'tool_result', tool_use_id: tu.id, content, ...(result.isError ? { is_error: true } : {}) },
684
+ stop: !!result.stopTurn,
685
+ };
686
+ }
687
+
688
+ // The summary in a summary request's reply: what is inside <summary>, else the text without its <analysis>.
689
+ // A reply that stops to call a tool has none, whatever text comes before the call ("Let me look at the file
690
+ // first."): that is a few words of preamble, and taking it for the summary would throw the conversation away.
691
+ // A reply that the output limit cut off before the summary was closed is no summary either: it would end in
692
+ // the middle of a sentence and the rest of the conversation would be gone.
693
+ static summaryOf(res) {
694
+ if (res.stopReason === 'tool_use' || res.message.content.some((b) => b.type === 'tool_use')) return '';
695
+ const text = res.message.content.filter((b) => b.type === 'text').map((b) => b.text).join('\n').trim();
696
+ if (res.stopReason === 'max_tokens' && !/<\/summary>/.test(text)) return '';
697
+ const summary = /<summary>([\s\S]*?)(<\/summary>|$)/.exec(text)?.[1]?.trim() || text.replace(/<analysis>[\s\S]*?<\/analysis>/, '').trim();
698
+ return summary.replace(/<\/?(?:summary|analysis)>/g, '').trim();
699
+ }
700
+
701
+ // Ask the model for the summary. The request is the conversation's own request with the summary instruction
702
+ // at its end: the same model, system prompt, tools and effort, so that the API reads the conversation from the
703
+ // cache the turns have been filling instead of processing it all again (this is what Claude Code does; it is
704
+ // the biggest request there is). Only if that does not give a summary (the model went on calling tools, or
705
+ // the request is too long by the size of the instruction) the plainer request is made: a short system prompt
706
+ // and no effort, which cannot share the cache, but is smaller and cannot be led astray by the main prompt.
707
+ async requestSummary(messages, { signal, onEvent }) {
708
+ const call = (request) => streamMessage(this.config, { model: this.model, tools: this.toolSchemas(), messages, cacheBucket: 'other', ...request }, { signal, onEvent });
709
+ try {
710
+ const res = await call({ system: this.currentSystem() });
711
+ this.recordUsage(res, 'summary');
712
+ if (Agent.summaryOf(res)) return res;
713
+ } catch (err) {
714
+ if (isAbort(err) || signal?.aborted || !err.promptTooLong) throw err;
715
+ }
716
+ const res = await call({ system: SUMMARY_SYSTEM, effort: null });
717
+ this.recordUsage({ ...res, cache: { ...res.cache, caching: false } }, 'summary'); // made to miss: not a miss to report
718
+ return res;
719
+ }
720
+
721
+ // Summarise the conversation and replace it with the summary.
722
+ async compact({ signal, auto = false, instructions = '' } = {}) {
723
+ if (this.messages.length === 0) return null;
724
+ this.io.compactStart?.(auto, this);
725
+ const messages = normalizeMessages([...this.messages, { role: 'user', content: [{ type: 'text', text: compactPrompt(instructions) }] }]);
726
+ // If the last assistant turn has unanswered tool calls, the sequence is invalid; drop it.
727
+ let res;
728
+ try {
729
+ res = await this.requestSummary(messages, { signal, onEvent: (ev) => this.io.compactProgress?.(ev, this) });
730
+ } catch (err) {
731
+ this.io.compactEnd?.(null, this, err);
732
+ throw err;
733
+ }
734
+ const summary = Agent.summaryOf(res);
735
+ if (!summary) {
736
+ const err = new Error(res.stopReason === 'max_tokens' ? 'Compaction failed: the summary was cut off by the output token limit.' : 'Compaction failed: the model returned an empty summary.');
737
+ this.io.compactEnd?.(null, this, err);
738
+ throw err;
739
+ }
740
+ const todoNote = this.todos.length ? `\n\n<system-reminder>Current todo list:\n${this.todos.map((t) => `- [${t.status}] ${t.content}`).join('\n')}</system-reminder>` : '';
741
+ this.replaceMessages([{ role: 'user', content: [{ type: 'text', text: continuationMessage(summary, { auto }) + todoNote }] }]);
742
+ this.refreshSystemPrompt?.(); // the conversation starts over: it may start from a freshly built system prompt
743
+ this.fileState.clear();
744
+ if (!this.isSubagent) this.checkpoints?.markConversationStale(); // the checkpoints are the main conversation's: a sub-agent's own compaction does not end them
745
+ this.lastContextTokens = estimateTokens(summary) + estimateTokens(this.toolSchemas()) + 2000;
746
+ this.tokensSinceUsage = 0;
747
+ this.postCompactTokens = null; // measured on the next request
748
+ this.compactFailures = 0;
749
+ this.io.compactEnd?.(summary, this);
750
+ return summary;
751
+ }
752
+ }