jaitus 0.3.11 → 0.3.13

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.
package/src/agent/loop.js CHANGED
@@ -1,980 +1,990 @@
1
- // ─── The agent loop ─────────────────────────────────────────────────────────────
2
- //
3
- // Send the conversation to Jaitus, stream the reply, run any tools it asked for, append
4
- // the results, and go again — until it stops asking for tools. That is the whole idea;
5
- // everything else here is making it behave under real conditions.
6
- //
7
- // Notes on the parts that are easy to get wrong:
8
- //
9
- // • Streaming is parsed as server-sent events. Tool arguments arrive as `input_json_delta`
10
- // fragments that only become valid JSON once concatenated, so they are buffered per
11
- // content block and parsed at the end, never incrementally.
12
- // • The assistant turn is appended EXACTLY as the model produced it, tool_use blocks
13
- // included. Paraphrasing it breaks the pairing with tool_result and the next turn
14
- // is rejected.
15
- // • Every tool_use must get a tool_result, including ones that failed or were refused.
16
- // A missing result is a malformed conversation, and the error text is also how the
17
- // model learns to try something else.
18
- // • A stuck detector, not a step count. A model caught in a loop would otherwise spend
19
- // real money until someone notices — but a fixed cap of 40 steps also stopped every
20
- // long, perfectly healthy task halfway and made the user type "continue". A run now
21
- // ends when the model finishes, or when it repeats the same call with the same input
22
- // STUCK_REPEATS times with no file changed in between (see `stuck` below). A very
23
- // high backstop remains so no run is unbounded.
24
-
25
- import { byName, toolSchemas, ToolError, readImageForModel } from './tools.js';
26
- import { PermissionDenied } from './permissions.js';
27
- import path from 'node:path';
28
- import fs from 'node:fs';
29
- import os from 'node:os';
30
- import { loadContext, loadIgnore, CONTEXT_FILE } from '../project.js';
31
- import { c, out, write, spinner, glyph } from '../ui.js';
32
- import { discoverMcpTools } from '../mcp.js';
33
- import { unifiedDiff } from '../diff.js';
34
- import { fromResponse, fromGatewayBody, JaitusError } from '../errors.js';
35
-
36
- const MAX_ITERATIONS = 1000;
37
- const STUCK_REPEATS = 4;
38
- const POLLING_TOOLS = new Set(['background_read', 'background_list']);
39
-
40
- // Room for a long answer or a large file in one reply. The models behind Jaitus reason
41
- // before they answer, and that reasoning is spent from the same budget — at 8192 a long
42
- // answer, or any file of a few hundred lines, was cut off partway.
43
- export const MAX_OUTPUT_TOKENS = 32000;
44
-
45
- // A reply that stopped before it finished — the output limit, or a connection dropped
46
- // somewhere between the provider and here — is continued, not presented as the answer.
47
- // Bounded, so a model that never finishes cannot loop on the user's allowance.
48
- export const MAX_CONTINUATIONS = 4;
49
- export const CONTINUE_NOTE = 'Your previous reply was cut off before it finished (an output limit or a dropped connection). '
50
- + 'Continue from exactly where it stopped: do not repeat or summarise what you already wrote, and do not mention this note. '
51
- + 'If you were in the middle of a tool call, make that call again in full.';
52
-
53
- // A request that failed before anything arrived (a network blip) is tried again this
54
- // many times before it is reported.
55
- const NETWORK_RETRIES = 2;
56
-
57
- // Gateway answers that mean "not right now", not "no".
58
- const TRANSIENT_CODES = new Set(['busy', 'unavailable', 'timeout', 'interrupted', 'internal', 'maintenance']);
59
- const BUSY_RETRIES = 3;
60
- const MAX_RETRY_WAIT_MS = 30_000;
61
-
62
- /** How long to wait before retrying `e`, or null when it must not be retried. */
63
- export function retryDelay(e, attempt = 0) {
64
- const code = e?.code;
65
- const hinted = Number(e?.retryAfter) > 0 ? Number(e.retryAfter) * 1000 : null;
66
- if (code === 'rate_limited') {
67
- // A short window is waited out; a cap that resets in hours is a limit, not a blip.
68
- if (hinted == null) return 2000 * (attempt + 1);
69
- return hinted <= MAX_RETRY_WAIT_MS ? hinted : null;
70
- }
71
- if (!TRANSIENT_CODES.has(code) || code === 'internal') return null;
72
- return Math.min(MAX_RETRY_WAIT_MS, hinted ?? 1500 * 2 ** attempt);
73
- }
74
-
75
- function sleepUnlessAborted(ms, signal) {
76
- return new Promise((resolve) => {
77
- if (signal?.aborted) return resolve(false);
78
- const t = setTimeout(() => { signal?.removeEventListener?.('abort', onAbort); resolve(true); }, ms);
79
- const onAbort = () => { clearTimeout(t); resolve(false); };
80
- signal?.addEventListener?.('abort', onAbort, { once: true });
81
- });
82
- }
83
-
84
- /** opencode tool icon per tool, muted when done. ASCII twins included. */
85
- const TOOL_MARKS = {
86
- run_command: ['$', '$'], read_file: ['→', '>'], read_image: ['→', '>'],
87
- list_files: ['→', '>'], write_file: ['←', '<'], edit_file: ['←', '<'],
88
- search: ['✱', '*'], web_fetch: ['%', '%'], web_search: ['◈', 'o'],
89
- background_run: ['◉', 'o'], todo_write: ['⚙', '*'],
90
- };
91
- const toolMark = (name) => {
92
- const [uni, ascii] = TOOL_MARKS[name] || ['⚙', '*'];
93
- return c.dim(glyph(uni, ascii));
94
- };
95
-
96
- export const SYSTEM_PROMPT = `You are Jaitus, a coding agent running in a developer's terminal, inside their project directory.
97
-
98
- You work directly on this computer. Your tools read, search, create and edit the files in
99
- the project and run shell commands in it, and you can fetch web pages. When the user asks
100
- about their files, their code or their machine, use the tools and answer from what you
101
- find — never say you cannot access local files, because you can.
102
-
103
- How to work:
104
- - Investigate before you change anything. Read the files you are about to edit; do not guess at their contents.
105
- - Prefer edit_file over write_file for existing files. Keep changes minimal and in the style of the surrounding code.
106
- - Run the project's own tests or build after a change when one exists, and report the real result — including when it fails.
107
- - Use run_command for anything the shell does better: git, tests, package managers, linters.
108
- - Never invent a file path, a function name, or a test result. If you are unsure, look.
109
- - Every turn re-sends the whole conversation, so make independent reads, searches and
110
- listings together in ONE turn rather than one per turn. Read the part of a large file you
111
- need (offset/limit) rather than all of it.
112
- - For current news or other web facts, report only details grounded in web_search or
113
- web_fetch output. Include the supplied source URLs; never fill a missing headline or
114
- date from memory.
115
-
116
- How to talk:
117
- - Be concise. This is a terminal, not a document.
118
- - Say what you did and what happened, not what you are about to do.
119
- - If something is blocked or you are missing information, say so plainly and ask.
120
- - No preamble, no restating the question back.`;
121
-
122
- /**
123
- * The project's own briefing, appended to the system prompt.
124
- *
125
- * Fenced and labelled rather than concatenated, so instructions written by whoever owns
126
- * the repo are visibly the repo's and cannot be mistaken for part of Jaitus's own rules.
127
- */
128
- /**
129
- * Where the agent is: the facts it would otherwise guess at, and guess wrong.
130
- *
131
- * Without these, the model did not know which folder it was in, which OS its commands
132
- * would run on (cmd.exe and /bin/sh disagree about almost everything), or today's date —
133
- * and a model that does not know where it is tends to say it cannot see your files. The
134
- * listing is the top level only, names not contents, and respects .jaitusignore.
135
- */
136
- export function environmentBlock(cwd, { now = new Date(), platform = process.platform } = {}) {
137
- if (!cwd) return '';
138
- const osName = { win32: 'Windows', darwin: 'macOS', linux: 'Linux' }[platform] || platform;
139
- const shell = platform === 'win32' ? 'cmd.exe (run_command uses it)' : '/bin/sh (run_command uses it)';
140
- // The repository may start above the working directory, and .git may be a file that
141
- // points elsewhere (a worktree or submodule).
142
- let branch = null;
143
- for (let dir = cwd, i = 0; i < 40; i++) {
144
- const dotGit = path.join(dir, '.git');
145
- try {
146
- let gitDir = dotGit;
147
- if (fs.statSync(dotGit).isFile()) {
148
- const m = /^gitdir:\s*(.+)$/m.exec(fs.readFileSync(dotGit, 'utf8'));
149
- if (m) gitDir = path.resolve(dir, m[1].trim());
150
- }
151
- const head = fs.readFileSync(path.join(gitDir, 'HEAD'), 'utf8').trim();
152
- branch = head.startsWith('ref: refs/heads/') ? head.slice(16) : `detached at ${head.slice(0, 7)}`;
153
- break;
154
- } catch { /* not here; look one level up */ }
155
- const parent = path.dirname(dir);
156
- if (parent === dir) break;
157
- dir = parent;
158
- }
159
- let listing = '';
160
- try {
161
- const ignore = loadIgnore(cwd);
162
- const SKIP = new Set(['.git', 'node_modules']);
163
- const names = fs.readdirSync(cwd, { withFileTypes: true })
164
- .filter((d) => !SKIP.has(d.name) && !ignore.matches(d.isDirectory() ? `${d.name}/` : d.name) && !ignore.matches(d.name))
165
- .map((d) => (d.isDirectory() ? `${d.name}/` : d.name))
166
- .sort((a, b) => (b.endsWith('/') - a.endsWith('/')) || a.localeCompare(b));
167
- const MAX = 60;
168
- listing = names.slice(0, MAX).join('\n') + (names.length > MAX ? `\n… and ${names.length - MAX} more` : '');
169
- } catch { /* unreadable: the tools will say why */ }
170
- return `
171
-
172
- <environment>
173
- Working directory: ${cwd}
174
- Platform: ${osName} (${os.release()})
175
- Shell: ${shell}
176
- Today: ${now.toISOString().slice(0, 10)}
177
- Git: ${branch ? `yes, branch ${branch}` : 'no repository at this folder'}
178
- Top level of the working directory:
179
- ${listing || '(empty)'}
180
- </environment>`;
181
- }
182
-
183
- export function systemPrompt(cwd) {
184
- const project = cwd ? loadContext(cwd) : null;
185
- const base = `${SYSTEM_PROMPT}${environmentBlock(cwd)}`;
186
- if (!project) return base;
187
- return `${base}
188
-
189
- The project you are working in ships a ${CONTEXT_FILE} with notes for you. Follow it where
190
- it applies, and prefer what it says over your own assumptions about this codebase.
191
-
192
- <${CONTEXT_FILE}>
193
- ${project}
194
- </${CONTEXT_FILE}>`;
195
- }
196
-
197
- /**
198
- * Every earlier image is sent again with every request, and the gateway accepts at most
199
- * 10 MB per request — three or four screenshots into a conversation, every turn failed.
200
- * The newest images are kept up to IMAGE_BUDGET bytes of base64; older ones are replaced
201
- * by a line saying one was there. The history itself is not changed.
202
- */
203
- /**
204
- * Make a history well-formed after any interruption: every assistant tool_use has a
205
- * tool_result in the next message. A run stopped by Esc, a failed permission prompt or
206
- * a gateway error partway through could leave a tool call with no result — and both
207
- * provider formats reject that, so every later request in the session failed. Missing
208
- * results are filled in as "not run"; an assistant turn that ends the history with tool
209
- * calls gets its results appended.
210
- */
211
- export function repairToolPairs(messages) {
212
- for (let i = 0; i < messages.length; i++) {
213
- const m = messages[i];
214
- if (m?.role !== 'assistant' || !Array.isArray(m.content)) continue;
215
- const ids = m.content.filter((b) => b?.type === 'tool_use').map((b) => b.id);
216
- if (!ids.length) continue;
217
- const next = messages[i + 1];
218
- const have = new Set(next?.role === 'user' && Array.isArray(next.content)
219
- ? next.content.filter((b) => b?.type === 'tool_result').map((b) => b.tool_use_id) : []);
220
- const missing = ids.filter((id) => !have.has(id));
221
- if (!missing.length) continue;
222
- const filled = missing.map((id) => result(id, 'Not run: the task was interrupted before this step.', true));
223
- if (next?.role === 'user') {
224
- const content = Array.isArray(next.content) ? next.content : [{ type: 'text', text: String(next.content ?? '') }];
225
- const results = content.filter((b) => b?.type === 'tool_result');
226
- const rest = content.filter((b) => b?.type !== 'tool_result');
227
- messages[i + 1] = { ...next, content: [...results, ...filled, ...rest] };
228
- } else {
229
- messages.splice(i + 1, 0, { role: 'user', content: filled });
230
- }
231
- }
232
- return messages;
233
- }
234
-
235
- // ── Keeping the conversation small ────────────────────────────────────────────
236
- //
237
- // Every request re-sends the whole conversation, and in real use that re-send — cached,
238
- // but not free — was ~75% of what a session cost: 109 requests averaged 46k tokens of
239
- // context each, most of it tool output from many steps ago (a file read at step 3 is
240
- // still paid for at step 60). This is the documented "clear old tool results" strategy
241
- // (Anthropic's context editing, clear_tool_uses; Claude Code's micro-compaction): once
242
- // the conversation passes TRIM_AT, the outputs of all but the KEEP_RECENT latest tool
243
- // calls are replaced with a line saying they were cleared and can be re-run. It clears
244
- // at least TRIM_AT_LEAST at a time, because each clearing breaks the prompt cache once
245
- // — clearing a little on every step would cost more than it saves. Small results (a
246
- // "file written", an exit code) are kept: they carry meaning and cost nothing.
247
- // 100k was the documented default for 200k-window models. Here the window is up to 1M, so
248
- // that ceiling was rarely near: production requests averaged 53k tokens and two thirds of
249
- // all input tokens came from requests over 60k. At 40k a clearing (one cache break, ~17
250
- // credits on glm-5.3-flash) pays for itself within about ten later requests.
251
- export const TRIM_AT = 40_000; // estimated tokens of conversation
252
- export const KEEP_RECENT = 6; // latest tool results always kept whole
253
- export const TRIM_AT_LEAST = 12_000; // estimated tokens a clearing must free
254
- const TRIM_MIN_CHARS = 1_500; // results shorter than this are never cleared
255
- const CLEARED = '[Output cleared';
256
-
257
- const blockChars = (b) => {
258
- if (b?.type === 'text') return (b.text || '').length;
259
- if (b?.type === 'tool_use') return JSON.stringify(b.input || {}).length;
260
- if (b?.type === 'tool_result') return typeof b.content === 'string' ? b.content.length : JSON.stringify(b.content || '').length;
261
- return 0;
262
- };
263
- export function conversationTokens(messages) {
264
- let chars = 0;
265
- for (const m of messages || []) chars += typeof m?.content === 'string' ? m.content.length : (Array.isArray(m?.content) ? m.content.reduce((n, b) => n + blockChars(b), 0) : 0);
266
- return Math.ceil(chars / 4);
267
- }
268
-
269
- /**
270
- * Clear old tool outputs in place when the conversation is large. Returns how many
271
- * estimated tokens were freed (0 when nothing was worth clearing).
272
- */
273
- export function clearOldToolResults(messages, { at = TRIM_AT, keep = KEEP_RECENT, atLeast = TRIM_AT_LEAST } = {}) {
274
- if (conversationTokens(messages) < at) return 0;
275
- const names = new Map();
276
- for (const m of messages) {
277
- if (m?.role === 'assistant' && Array.isArray(m.content)) for (const b of m.content) if (b?.type === 'tool_use') names.set(b.id, b.name);
278
- }
279
- const results = [];
280
- for (const m of messages) {
281
- if (m?.role !== 'user' || !Array.isArray(m.content)) continue;
282
- m.content.forEach((b, i) => { if (b?.type === 'tool_result') results.push({ m, i, b }); });
283
- }
284
- const candidates = results.slice(0, Math.max(0, results.length - keep)).filter(({ b }) => {
285
- const text = typeof b.content === 'string' ? b.content : '';
286
- return text.length >= TRIM_MIN_CHARS && !text.startsWith(CLEARED);
287
- });
288
- const freed = Math.ceil(candidates.reduce((n, { b }) => n + blockChars(b), 0) / 4);
289
- if (freed < atLeast) return 0;
290
- for (const { m, i, b } of candidates) {
291
- const chars = blockChars(b);
292
- const name = names.get(b.tool_use_id) || 'this tool';
293
- m.content[i] = { ...b, content: `${CLEARED}: ${name} returned ${chars.toLocaleString()} characters here, removed to keep the conversation small. Run it again if you need it.]` };
294
- }
295
- return freed;
296
- }
297
-
298
- export const IMAGE_BUDGET = 6_000_000;
299
- export function withinImageBudget(messages, budget = IMAGE_BUDGET) {
300
- let used = 0;
301
- let changed = false;
302
- const out = messages.slice();
303
- for (let i = out.length - 1; i >= 0; i--) {
304
- const m = out[i];
305
- if (!Array.isArray(m?.content) || !m.content.some((b) => b?.type === 'image')) continue;
306
- // Walk this message's blocks newest-last, so within a message the later images win.
307
- const kept = new Array(m.content.length);
308
- for (let j = m.content.length - 1; j >= 0; j--) {
309
- const b = m.content[j];
310
- if (b?.type !== 'image') { kept[j] = b; continue; }
311
- const size = String(b.source?.data || '').length;
312
- if (used + size <= budget) { used += size; kept[j] = b; continue; }
313
- kept[j] = { type: 'text', text: '[An image was attached here earlier; it is no longer included, to keep the request small.]' };
314
- changed = true;
315
- }
316
- out[i] = { ...m, content: kept };
317
- }
318
- return changed ? out : messages;
319
- }
320
-
321
- /** One streamed request. Returns { content, stopReason, usage }. */
322
- async function streamTurn({ client, messages, model, system, tools, onText, onToolStart, signal }) {
323
- const res = await client.messages({
324
- model,
325
- max_tokens: MAX_OUTPUT_TOKENS,
326
- system: system || SYSTEM_PROMPT,
327
- tools,
328
- messages: withinImageBudget(messages),
329
- stream: true,
330
- }, { signal });
331
-
332
- if (!res.ok) throw await fromResponse(res);
333
- const requestId = res.headers?.get?.('x-request-id') || '';
334
-
335
- // Blocks are keyed by the index the stream gives them, because they interleave.
336
- const blocks = new Map();
337
- let stopReason = null;
338
- // Whether the stream ended the way a finished message ends. A stream that just stops
339
- // — no message_stop, no stop reason — was cut, and used to be taken as a complete,
340
- // silent answer: the "it stops midway for no reason" bug.
341
- let sawStop = false;
342
- // `credits` is what the gateway counted against the plan for this request — real cost,
343
- // the one unit the plan, the dashboard and this CLI all show.
344
- const usage = { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, credits: 0 };
345
- // Usage can arrive at the START of a stream (Anthropic-format providers) or only at
346
- // the END, in message_delta (OpenAI-format providers, translated by the gateway —
347
- // their usage exists only once generation finishes). Reading input from message_start
348
- // alone recorded every OpenAI-format turn as 0 input tokens.
349
- const takeUsage = (u) => {
350
- if (!u) return;
351
- for (const k of ['input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens', 'credits']) {
352
- if (u[k] != null && Number.isFinite(Number(u[k]))) usage[k] = Number(u[k]);
353
- }
354
- };
355
- let buffer = '';
356
- // A stream that never ends must die here, not in GC hell: the buffer below grows
357
- // with every chunk and each arrival re-splits all of it, so a runaway provider
358
- // (twelve silent minutes and counting, seen in the wild) grows memory
359
- // quadratically until V8 falls over near 4 GB. Past the cap the turn ends as a
360
- // cut (continued from what arrived) or, with nothing received, as a retryable
361
- // timeout — never as a crash.
362
- const MAX_STREAM_BUFFER = 8 * 1024 * 1024;
363
- // A failure reported INSIDE the stream, after a 200. Recorded here and thrown after
364
- // the parse below: handle() runs inside a try whose catch exists to skip keepalives,
365
- // so throwing from it would be swallowed — which is how a mid-stream error used to
366
- // end an answer halfway with no explanation at all.
367
- let streamError = null;
368
-
369
- const handle = (event) => {
370
- switch (event.type) {
371
- case 'error':
372
- streamError = fromGatewayBody(200, event, { requestId });
373
- break;
374
- case 'message_start':
375
- takeUsage(event.message?.usage);
376
- break;
377
- case 'content_block_start': {
378
- const b = event.content_block || {};
379
- blocks.set(event.index, b.type === 'tool_use'
380
- ? { type: 'tool_use', id: b.id, name: b.name, json: '' }
381
- : { type: 'text', text: '' });
382
- if (b.type === 'tool_use') onToolStart?.(b.name);
383
- break;
384
- }
385
- case 'content_block_delta': {
386
- const b = blocks.get(event.index);
387
- if (!b) break;
388
- const d = event.delta || {};
389
- if (d.type === 'text_delta' && typeof d.text === 'string') {
390
- b.text += d.text;
391
- onText?.(d.text);
392
- } else if (d.type === 'input_json_delta' && typeof d.partial_json === 'string') {
393
- b.json += d.partial_json;
394
- }
395
- break;
396
- }
397
- case 'message_delta':
398
- if (event.delta?.stop_reason) stopReason = event.delta.stop_reason;
399
- takeUsage(event.usage);
400
- break;
401
- case 'message_stop':
402
- sawStop = true;
403
- break;
404
- default:
405
- break;
406
- }
407
- };
408
-
409
- // fetch() yields Uint8Array, NOT a Node Buffer. `uint8.toString('utf8')` ignores the
410
- // argument and returns "101,118,101,…" — comma-joined byte values — so the whole
411
- // stream parses to nothing and the agent appears to answer with silence.
412
- //
413
- // TextDecoder with stream:true is also the only correct choice for a different reason:
414
- // a multi-byte character can be split across two chunks, and decoding each chunk
415
- // independently would corrupt it. The decoder holds the partial bytes until complete.
416
- const decoder = new TextDecoder();
417
-
418
- let cut = false;
419
- try {
420
- for await (const chunk of res.body) {
421
- buffer += decoder.decode(chunk, { stream: true });
422
- if (buffer.length > MAX_STREAM_BUFFER) {
423
- // Breaking the loop releases the reader (the socket is cancelled), so the
424
- // runaway stops arriving too — not just stops being kept.
425
- if (!blocks.size) throw new JaitusError({ code: 'timeout', status: 504, message: 'Jaitus took too long to respond. Try again.' });
426
- cut = true;
427
- break;
428
- }
429
- const frames = buffer.split('\n\n');
430
- buffer = frames.pop() || '';
431
- for (const frame of frames) {
432
- for (const line of frame.split('\n')) {
433
- if (!line.startsWith('data:')) continue;
434
- const raw = line.slice(5).trim();
435
- if (!raw || raw === '[DONE]') continue;
436
- try { handle(JSON.parse(raw)); } catch { /* keepalive or partial */ }
437
- }
438
- }
439
- if (streamError) break;
440
- }
441
- } catch (e) {
442
- // Esc is the only abort that reads as an interruption. Any OTHER AbortError —
443
- // the request timeout in api.js firing mid-body, a dropped socket — is a `timeout`
444
- // JaitusError so the agent loop backs off and retries it (TRANSIENT_CODES) instead
445
- // of reporting "Interrupted." for something the user never stopped.
446
- if (signal?.aborted) throw e;
447
- if (e?.name === 'AbortError') throw new JaitusError({ code: 'timeout', status: 504, message: 'Jaitus took too long to respond. Try again.' });
448
- // The connection dropped mid-answer. What arrived is kept and continued from; with
449
- // nothing received yet, the caller retries the request instead.
450
- if (!blocks.size) throw e;
451
- cut = true;
452
- }
453
- if (streamError) {
454
- // The provider dropped out after part of the answer had arrived. What arrived is
455
- // kept and continued from, exactly as when OUR connection drops — it used to be
456
- // thrown away with a "Something went wrong" and the task stopped.
457
- if (blocks.size && TRANSIENT_CODES.has(streamError.code)) cut = true;
458
- else throw streamError;
459
- }
460
- if (cut || (!sawStop && !stopReason)) stopReason = 'cut';
461
-
462
- const content = [...blocks.entries()]
463
- .sort((a, b) => a[0] - b[0])
464
- .map(([, b]) => {
465
- if (b.type !== 'tool_use') return { type: 'text', text: b.text };
466
- let input = {};
467
- // The fragments only form valid JSON once joined. An empty string is a
468
- // no-argument call, which is legitimate.
469
- try { input = b.json.trim() ? JSON.parse(b.json) : {}; } catch { input = { __parse_error: b.json }; }
470
- return { type: 'tool_use', id: b.id, name: b.name, input };
471
- })
472
- .filter((b) => b.type !== 'text' || b.text.length);
473
-
474
- return { content, stopReason, usage };
475
- }
476
-
477
- /**
478
- * Run one user request to completion.
479
- * `messages` is mutated so the caller keeps the conversation across turns.
480
- */
481
- export async function runAgent({
482
- client, messages, model, cwd, permissions, signal, tui = null,
483
- extraSystem = '', maxIterations = MAX_ITERATIONS, silent = false,
484
- onText = null, onEvent = null, systemOverride = null, journal = null,
485
- subagents = true, onContext = null, trimContext = true,
486
- }) {
487
- // Loop detection. A call is identified by its tool, its input, and how many files had
488
- // been changed when it was made — so edit → test → edit → test is progress (the count
489
- // moves), while reading the same file over and over is not.
490
- let changes = 0;
491
- const repeats = new Map();
492
- // input = fresh input, cached = prompt-cache reads (billed far cheaper), and
493
- // context = the real size of the last prompt sent, as the provider counted it.
494
- const totals = { input: 0, cached: 0, output: 0, tools: 0, context: 0, credits: 0 };
495
- // Read once per request, not per turn: an agent that rewrites JAITUS.md mid-task
496
- // should not change the rules it is being judged by halfway through.
497
- // The model is told when it is in plan mode — it used to find out only by having its
498
- // writes refused, after it had already tried them.
499
- const planNote = permissions?.mode === 'plan'
500
- ? '\n\nPLAN MODE is on: you can read, list, search and fetch, but nothing you write and no command you run will be carried out. Investigate, then answer with a concrete plan (files, changes, order, risks). The user switches modes when they want it done.'
501
- : '';
502
- const system = `${systemOverride || systemPrompt(cwd)}${extraSystem}${planNote}`;
503
- const mcp = await discoverMcpTools(cwd).catch(() => []);
504
- // Subagents are offered only to the top-level agent. A subagent that can spawn
505
- // subagents is an unbounded fan-out on a metered gateway, and the depth that would
506
- // actually help — one — is the depth that is safe.
507
- const subagent = subagents ? makeSubagentTool({ client, model, cwd, signal, tui, permissions }) : null;
508
- const availableTools = [
509
- ...toolSchemas(),
510
- ...(subagent ? [{ name: subagent.name, description: subagent.description, input_schema: subagent.input_schema }] : []),
511
- ...mcp.map((tool) => ({ name: tool.name, description: tool.description, input_schema: tool.input_schema })),
512
- ];
513
- const toolMap = {
514
- ...byName,
515
- ...(subagent ? { [subagent.name]: subagent } : {}),
516
- ...Object.fromEntries(mcp.map((tool) => [tool.name, tool])),
517
- };
518
-
519
- let continuations = 0;
520
- let networkRetries = 0;
521
- let busyRetries = 0;
522
- let planNoticeShown = false;
523
- // True while the previous reply is being continued: its text is still on screen as
524
- // one live message, and the continuation must extend it rather than start another.
525
- let continuing = false;
526
-
527
- for (let iteration = 0; iteration < maxIterations; iteration++) {
528
- // Old tool outputs are cleared once the conversation is large (see TRIM_AT) — in
529
- // place, so the saved session shrinks too and the cache stays warm afterwards.
530
- if (trimContext) {
531
- const freed = clearOldToolResults(messages);
532
- if (freed && tui?.notice) tui.notice(`Cleared old tool output (~${Math.round(freed / 1000)}k tokens) to keep each request small.`, 'info');
533
- }
534
- let streamed = false;
535
- const spin = tui ? { stop: () => {}, update: () => {} } : (silent ? { stop: () => {}, update: () => {} } : spinner(glyph('Working…', 'Working...')));
536
- if (tui) tui.setStatus(`${c.brand(glyph('•', '*'))} ${c.brand(glyph('Working…', 'Working...'))}`);
537
- // Codex-style assistant cell: the • leads the turn the way › leads the
538
- // user's. In scrollback the marker lands before the first streamed chunk.
539
- let turnVoiced = false;
540
-
541
- let turn;
542
- try {
543
- turn = await streamTurn({
544
- client,
545
- messages,
546
- model,
547
- system,
548
- tools: availableTools,
549
- signal,
550
- onText: (text) => {
551
- onText?.(text);
552
- if (!streamed) {
553
- spin.stop(); streamed = true;
554
- if (tui) tui.clearStatus();
555
- else if (!silent && !onText) { out(''); write(`${c.dim(glyph('•', '*'))} `); }
556
- }
557
- if (tui) {
558
- // The transcript entry draws its own speaker mark now, so the loop no
559
- // longer prepends one — two marks on the same line was the tell that the
560
- // renderer and the loop each thought they owned the lead glyph.
561
- turnVoiced = true;
562
- tui.printStream(text);
563
- } else if (!silent && !onText) write(text);
564
- },
565
- onToolStart: () => spin.update(glyph('Working…', 'Working...')),
566
- });
567
- } catch (e) {
568
- spin.stop();
569
- // Esc during streaming: stop mid-sentence, keep what landed, don't treat it
570
- // as an error. The partial text is already on screen.
571
- // A network failure before anything arrived: try again, briefly, before reporting.
572
- // Anything the gateway itself refused (a limit, a sign-in problem) has a status and
573
- // is reported at once — retrying it would only repeat the refusal.
574
- if (!(signal?.aborted || e?.name === 'AbortError') && !e?.status && networkRetries < NETWORK_RETRIES) {
575
- networkRetries += 1;
576
- await new Promise((r) => setTimeout(r, 800 * networkRetries));
577
- iteration -= 1;
578
- continue;
579
- }
580
- // Busy, rate-limited for a moment, unavailable, timed out: the gateway says to
581
- // back off and retry (it sends Retry-After), and nothing used to — one provider 429
582
- // ended a task halfway. A limit (plan, free tier, a daily cap hours away) or a
583
- // sign-in problem is never retried.
584
- const wait = retryDelay(e, busyRetries);
585
- if (!(signal?.aborted || e?.name === 'AbortError') && wait != null && busyRetries < BUSY_RETRIES) {
586
- busyRetries += 1;
587
- const secs = Math.max(1, Math.round(wait / 1000));
588
- if (tui?.setStatus) tui.setStatus(`${c.yellow('!')} ${c.dim(`Jaitus is busy — trying again in ${secs}s (${busyRetries} of ${BUSY_RETRIES})`)}`);
589
- const ok = await sleepUnlessAborted(wait, signal);
590
- tui?.clearStatus?.();
591
- if (!ok) return { ...totals, stopReason: 'interrupted' };
592
- iteration -= 1;
593
- continue;
594
- }
595
- if (signal?.aborted || e?.name === 'AbortError') {
596
- if (tui) tui.flushStream();
597
- if (streamed && !silent) out('');
598
- if (tui?.notice) tui.notice('Interrupted.', 'warn');
599
- else if (tui) tui.print(c.yellow(` ${glyph('⏹', '[x]')} Interrupted.`));
600
- else if (!silent) out(c.yellow(` ${glyph('⏹', '[x]')} Interrupted.`));
601
- return { ...totals, stopReason: 'interrupted' };
602
- }
603
- throw e;
604
- }
605
- spin.stop();
606
- networkRetries = 0;
607
- busyRetries = 0;
608
- // A reply the provider's content filter stopped is cut short; say so rather than
609
- // letting half an answer pass for the whole one.
610
- if (turn.stopReason === 'refusal') {
611
- const why = 'The answer was stopped partway by a content filter. Rephrasing the request usually gets past it.';
612
- if (tui?.notice) tui.notice(why, 'warn');
613
- else if (!silent) out(c.yellow(` ${why}`));
614
- }
615
- const incomplete = turn.stopReason === 'max_tokens' || turn.stopReason === 'cut';
616
- // A tool call cut off halfway has JSON that is not whole; it is dropped and the model
617
- // is asked to make it again.
618
- if (incomplete) turn.content = turn.content.filter((b) => b.type !== 'tool_use' || b.input?.__parse_error === undefined);
619
- const willContinue = incomplete && continuations < MAX_CONTINUATIONS && !turn.content.some((b) => b.type === 'tool_use');
620
- if (!willContinue) {
621
- if (tui) tui.flushStream();
622
- if (streamed || continuing) { if (tui) tui.printStream('\n'); else if (!silent && !onText) out(''); }
623
- }
624
-
625
- totals.input += turn.usage.input_tokens || 0;
626
- totals.cached += (turn.usage.cache_read_input_tokens || 0) + (turn.usage.cache_creation_input_tokens || 0);
627
- totals.output += turn.usage.output_tokens || 0;
628
- totals.credits += Number(turn.usage.credits) || 0;
629
- const promptSize = (turn.usage.input_tokens || 0) + (turn.usage.cache_read_input_tokens || 0) + (turn.usage.cache_creation_input_tokens || 0);
630
- if (promptSize) {
631
- totals.context = promptSize + (turn.usage.output_tokens || 0);
632
- // The header's context meter moves after every step. It used to be told only when
633
- // the whole run returned, so through a long task — and after one stopped with
634
- // Esc or an error — it sat at 0.
635
- try { onContext?.(totals.context); } catch { /* the meter never breaks a turn */ }
636
- }
637
- // The work bar's live count. It was declared and drawn but never fed, so it never
638
- // appeared; now it moves after every step of the turn.
639
- tui?.setTurnTokens?.(totals.input + totals.cached + totals.output);
640
-
641
- if (willContinue) {
642
- continuations += 1;
643
- continuing = true;
644
- const text = turn.content.filter((b) => b.type === 'text');
645
- if (text.length) messages.push({ role: 'assistant', content: text });
646
- messages.push({ role: 'user', content: [{ type: 'text', text: CONTINUE_NOTE }] });
647
- onEvent?.({ type: 'continued', reason: turn.stopReason, count: continuations });
648
- continue;
649
- }
650
- continuing = false;
651
- if (incomplete && !turn.content.some((b) => b.type === 'tool_use')) {
652
- // Still unfinished after every continuation: say so, rather than let a partial
653
- // answer pass as a whole one.
654
- const why = 'The reply was still unfinished after several continuations. Ask for the rest, or narrow the request.';
655
- if (tui?.notice) tui.notice(why, 'warn');
656
- else if (!silent) out(c.yellow(` ${why}`));
657
- }
658
-
659
- // Append the assistant turn verbatim — tool_use blocks and all.
660
- messages.push({ role: 'assistant', content: turn.content });
661
- onEvent?.({ type: 'assistant_turn', content: turn.content, usage: turn.usage, iteration });
662
-
663
- const calls = turn.content.filter((b) => b.type === 'tool_use');
664
-
665
- // Plan mode: read-only by decree. The turn's text still lands (the model can think
666
- // out loud), but nothing on disk or in a shell is allowed to happen.
667
- // web_fetch is not a write: in plan mode it goes to its own prompt instead of ending the turn.
668
- // Plan mode: a write or command gets a "not run — plan instead" result, the reads in
669
- // the same batch still run, and the turn — with the plan the model just wrote — stays
670
- // in the history. It used to pop the whole turn, so "ok, do it" found no plan to do.
671
- const planBlocked = permissions.mode === 'plan'
672
- ? new Set(calls.filter((call) => toolMap[call.name]?.needsPermission && !toolMap[call.name]?.network).map((call) => call.id))
673
- : new Set();
674
- if (planBlocked.size && !planNoticeShown) {
675
- planNoticeShown = true;
676
- const text = 'Plan mode — writes and commands are not run; the agent can still read. Shift+Tab (or /code) to let it act.';
677
- if (tui?.notice) tui.notice(text, 'warn');
678
- else if (!silent) out(c.yellow(` ${glyph('⏸', '[II]')} ${text}`));
679
- }
680
-
681
- // Esc pressed while the model was mid-turn: stop before any more tools run.
682
- if (signal?.aborted) {
683
- if (!silent) out(c.yellow(` ${glyph('⏹', '[x]')} Interrupted.`));
684
- return { ...totals, stopReason: 'interrupted' };
685
- }
686
-
687
- if (!calls.length) { tui?.bellIfAway?.(); return { ...totals, stopReason: turn.stopReason }; }
688
-
689
- // The same call, same input, nothing changed since: STUCK_REPEATS times is a loop.
690
- // Checked before running, so the repeat that proves it is not paid for again.
691
- const stuckOn = calls.find((call) => {
692
- // Polling is supposed to repeat: background_read's own description says "poll
693
- // this", and four polls of a starting dev server are not a loop.
694
- if (POLLING_TOOLS.has(call.name)) return false;
695
- const key = `${changes}\u0000${call.name}\u0000${JSON.stringify(call.input ?? null)}`;
696
- const n = (repeats.get(key) || 0) + 1;
697
- repeats.set(key, n);
698
- return n >= STUCK_REPEATS;
699
- });
700
- if (stuckOn) {
701
- // Every tool_use still gets a result — the model is told why these did not run, so
702
- // after "continue" it does not simply make the same call a fifth time.
703
- messages.push({ role: 'user', content: calls.map((call) => result(call.id,
704
- `Not run: this exact call was already made ${STUCK_REPEATS - 1} times with no file changed since, and the run was stopped as stuck. Do something different.`, true)) });
705
- const label = toolMap[stuckOn.name]?.summarise ? toolMap[stuckOn.name].summarise(stuckOn.input) : stuckOn.name;
706
- const why = `Stopped: it made the same ${stuckOn.name} call (${String(label).slice(0, 60)}) ${STUCK_REPEATS} times without changing anything, so it looks stuck. Tell it what to do differently, or say "continue".`;
707
- if (tui?.notice) tui.notice(why, 'warn');
708
- else if (!silent) out(c.yellow(`\n${why}`));
709
- tui?.bellIfAway?.();
710
- return { ...totals, stopReason: 'stuck' };
711
- }
712
-
713
- // Every call gets a result, whatever happened.
714
- const results = [];
715
- for (const call of calls) {
716
- const tool = toolMap[call.name];
717
- totals.tools += 1;
718
-
719
- // Esc during a batch stops the REST of it. The check used to run once before the
720
- // batch, so after Esc the remaining calls still prompted and ran.
721
- if (signal?.aborted) {
722
- results.push(result(call.id, 'Not run: the user stopped the task.', true));
723
- continue;
724
- }
725
- if (planBlocked.has(call.id)) {
726
- results.push(result(call.id, 'Not run: plan mode is on, so nothing is written and no command runs. Finish investigating with read-only tools, then present your plan as your answer — the user will switch modes when they want it carried out.', true));
727
- continue;
728
- }
729
- if (!tool) {
730
- results.push(result(call.id, `No such tool: ${call.name}`, true));
731
- continue;
732
- }
733
- if (call.input?.__parse_error !== undefined) {
734
- results.push(result(call.id, 'Your tool arguments were not valid JSON. Send them again.', true));
735
- continue;
736
- }
737
-
738
- const label = tool.summarise ? tool.summarise(call.input) : call.name;
739
- const activityId = tui?.recordActivity?.({ name: call.name, label, status: 'waiting' });
740
- // One row per call, mutated in place through waiting → running → ok/failed. The
741
- // previous surface printed a fresh line per state change, so a session with
742
- // twenty tool calls scrolled sixty lines of near-duplicates past the answer.
743
- const row = tui?.tool?.({ name: call.name, label, status: 'waiting', file: call.input?.path });
744
-
745
- try {
746
- await permissions.request(tool, call.input, { cwd, tui, signal });
747
- } catch (e) {
748
- if (e instanceof PermissionDenied) {
749
- const declined = ` ${c.red(glyph('✗', 'x'))} ${c.dim('declined')}`;
750
- if (row) row.update({ status: 'denied', error: e.comment ? `declined — "${e.comment}"` : 'declined' });
751
- else if (tui) tui.print(declined);
752
- else if (!silent) out(declined);
753
- tui?.recordActivity?.({ id: activityId, name: call.name, label, status: 'failed', detail: 'Permission declined by user.' });
754
- // Told to the model, not just the user: it needs to know to stop trying and
755
- // propose something else instead. A decision comment rides along verbatim.
756
- // A run that could not ask (one-shot, a script) says so — "the user declined"
757
- // was untrue there, and hid the hint about --yes from everyone.
758
- if (e.headless) totals.denied = (totals.denied || 0) + 1;
759
- const note = e.headless
760
- ? `Not permitted in this run: ${e.message} Do not retry it; finish what you can without it and say what was left undone.`
761
- : e.comment
762
- ? `The user declined this action with this instruction, which overrides your plan: "${e.comment}". Do not retry the declined action; follow the instruction instead.`
763
- : `The user declined this action. Do not retry it; ask what they would prefer.`;
764
- results.push(result(call.id, note, true));
765
- continue;
766
- }
767
- throw e;
768
- }
769
-
770
- tui?.recordActivity?.({ id: activityId, name: call.name, label, status: 'running' });
771
- row?.update({ status: 'running' });
772
- const spin2 = tui ? { stop: () => {} } : (silent ? { stop: () => {} } : spinner(`${call.name}: ${label}`.slice(0, 100)));
773
- const toolStarted = Date.now();
774
- // Live elapsed ticker on the status row while a tool runs (1s cadence,
775
- // silent when animations are off — progress you can watch, not noise).
776
- let elapsedTimer = null;
777
- const stopElapsed = () => { if (elapsedTimer) { clearInterval(elapsedTimer); elapsedTimer = null; } };
778
- if (tui && tui.animationsOn?.()) {
779
- const toolStatus = () => `${c.brand(glyph('•', '*'))} ${call.name}: ${c.dim(String(label).slice(0, 60))} ${c.dim(`· ${((Date.now() - toolStarted) / 1000).toFixed(0)}s`)}`;
780
- tui.setStatus(toolStatus());
781
- elapsedTimer = setInterval(() => tui.setStatus(toolStatus()), 1000);
782
- if (elapsedTimer.unref) elapsedTimer.unref();
783
- } else if (tui) {
784
- tui.setStatus(`${c.brand(glyph('•', '*'))} ${call.name}: ${c.dim(String(label).slice(0, 70))}`);
785
- }
786
- try {
787
- // Rewind safety net: before the agent overwrites or creates a file, remember
788
- // what was there. write_file/edit_file take their targets from the input;
789
- // run_command's target is unknowable, so its calls snapshot the whole
790
- // tracked set instead (cheap: only files already touched this session).
791
- if (journal && ['write_file', 'edit_file'].includes(call.name) && call.input?.path) {
792
- try { journal.snapshotFile(path.resolve(cwd, String(call.input.path))); } catch { /* path never existed */ }
793
- } else if (journal && call.name === 'run_command') {
794
- journal.snapshotTracked(cwd);
795
- }
796
- const output = await tool.run(call.input, { cwd, signal });
797
- spin2.stop(); stopElapsed();
798
- const took = ((Date.now() - toolStarted) / 1000).toFixed(1);
799
- // opencode tool icons: $ shell, ← edits, → reads, ✱ search,
800
- // % fetch, ◈ websearch, ◉ subagents, ⚙ everything else. Done
801
- // rows rest muted; failures stay red.
802
- const done = ` ${toolMark(call.name)} ${c.dim(String(label).slice(0, 100))} ${c.dim(`· ${took}s`)}`;
803
- if (row) {
804
- tui.clearStatus();
805
- // The whole output travels with the row (capped), so a click can open it.
806
- row.update({ status: 'ok', took, preview: previewFor(call.name, output), full: String(output ?? '').slice(0, 200_000) });
807
- } else if (tui) { tui.clearStatus(); tui.print(done, 'tool'); } else if (!silent) out(done);
808
- tui?.recordActivity?.({ id: activityId, name: call.name, label, status: 'ok', detail: String(output).slice(0, 2000) });
809
- onEvent?.({ type: 'tool_result', name: call.name, input: call.input, ok: true, output });
810
- results.push(result(call.id, output, false));
811
- // A changed file is progress: calls made after it are not repeats of those before.
812
- if (['write_file', 'edit_file'].includes(call.name)) changes += 1;
813
- // read_image: the pixels ride along as an image block in the tool-result
814
- // message, so the model actually sees the picture instead of a caption of it.
815
- // read_image: the pixels ride along in this message, after the results. When no
816
- // image can be attached the call FAILED and says why — it used to report success
817
- // either way, and the model then described a picture it never saw.
818
- if (call.name === 'read_image') {
819
- let block = null;
820
- let why = '';
821
- try { block = await readImageForModel(cwd, call.input?.path); } catch (e) { why = e.message; }
822
- results[results.length - 1] = block
823
- ? { ...result(call.id, 'The image is attached to this message, after the tool results.', false), __images: [block] }
824
- : result(call.id, why || 'The image could not be attached.', true);
825
- }
826
- // todo_write: the checklist is for the human — checkbox cells with progress.
827
- if (call.name === 'todo_write' && Array.isArray(call.input?.todos)) {
828
- if (tui?.todo) tui.todo(call.input.todos);
829
- else if (!silent) {
830
- const icon = { pending: c.dim(glyph('☐', '[ ]')), in_progress: c.accent(glyph('◐', '[>]')), completed: c.accent(glyph('☑', '[x]')) };
831
- for (const t of call.input.todos.slice(0, 12)) {
832
- out(` ${(icon[t?.status] || icon.pending)} ${t?.status === 'completed' ? c.dim(String(t?.task || '').slice(0, 90)) : String(t?.task || '').slice(0, 90)}`);
833
- }
834
- }
835
- }
836
-
837
- // A write or an edit shows what it actually did. The journal snapshotted the
838
- // file a few lines above, so the "before" side is already in memory — and a
839
- // claim like "Edited src/index.js" is exactly the kind of thing that turns out
840
- // to have changed the wrong line.
841
- if (tui?.diff && journal && ['write_file', 'edit_file'].includes(call.name) && call.input?.path) {
842
- try {
843
- const absolute = path.resolve(cwd, String(call.input.path));
844
- const before = journal.latestSnapshot(absolute);
845
- if (before !== null) {
846
- const { readFileSync } = await import('node:fs');
847
- const patch = unifiedDiff(before, readFileSync(absolute, 'utf8'), { file: call.input.path });
848
- if (patch) tui.diff(String(call.input.path), patch);
849
- }
850
- } catch { /* a missing diff never blocks the turn */ }
851
- }
852
- } catch (e) {
853
- spin2.stop(); stopElapsed();
854
- const msg = e instanceof ToolError ? e.message : `${e.message}`;
855
- const took = ((Date.now() - toolStarted) / 1000).toFixed(1);
856
- const bad = ` ${c.red(glyph('✗', 'x'))} ${c.dim(String(label).slice(0, 80))} ${c.dim(`· ${took}s`)} — ${msg.slice(0, 120)}`;
857
- if (row) {
858
- tui.clearStatus();
859
- row.update({ status: 'failed', took, error: msg.split('\n')[0].slice(0, 200), preview: msg.includes('\n') ? msg : '', full: msg.slice(0, 200_000) });
860
- } else if (tui) { tui.clearStatus(); tui.print(bad, 'tool'); } else if (!silent) out(bad);
861
- tui?.recordActivity?.({ id: activityId, name: call.name, label, status: 'failed', detail: msg.slice(0, 2000) });
862
- onEvent?.({ type: 'tool_result', name: call.name, input: call.input, ok: false, error: msg });
863
- results.push(result(call.id, msg, true));
864
- }
865
- }
866
-
867
- // Every tool_result first, then any images, in the same user message. Anthropic's
868
- // format requires the results to lead the message; an image between two results
869
- // (read_image followed by another call in one batch) was a rejected request.
870
- const images = [];
871
- for (const r of results) {
872
- for (const img of r.__images || []) images.push(img);
873
- delete r.__images;
874
- }
875
- messages.push({ role: 'user', content: [...results, ...images] });
876
- if (signal?.aborted) {
877
- if (!silent) out(c.yellow(` ${glyph('⏹', '[x]')} Interrupted.`));
878
- return { ...totals, stopReason: 'interrupted' };
879
- }
880
- }
881
-
882
- if (!silent) out(c.yellow(`\nStopped after ${maxIterations} steps — the safety limit for one request. Say "continue" to keep going.`));
883
- tui?.bellIfAway?.();
884
- return { ...totals, stopReason: 'max_iterations' };
885
- }
886
-
887
- /**
888
- * A read-only subagent, as a tool the model can call.
889
- *
890
- * The point is context, not concurrency. "Find every place that reads the session file"
891
- * costs forty tool results, and forty tool results spent in the main conversation are
892
- * forty tool results the main conversation is still carrying three turns later. A
893
- * subagent spends them in its own window and returns the paragraph that mattered.
894
- *
895
- * Read-only by construction: it is handed a permissions object in plan mode, so every
896
- * write and every shell command is refused no matter what the parent's mode is. A
897
- * subagent the user never sees must not be able to change the disk.
898
- */
899
- function makeSubagentTool({ client, model, cwd, signal, tui, permissions = null }) {
900
- return {
901
- name: 'task',
902
- needsPermission: false,
903
- description: 'Delegate a read-only investigation to a separate agent with its own context window. '
904
- + 'Use it when answering needs a lot of searching and reading whose intermediate output you do not need to keep — '
905
- + '"find every caller of X", "work out how Y is wired", "check whether Z is covered by tests". '
906
- + 'The subagent can read, list, search and fetch, but cannot write files or run commands. '
907
- + 'Give it a self-contained brief: it cannot see this conversation. It returns its findings as text.',
908
- input_schema: {
909
- type: 'object',
910
- properties: {
911
- description: { type: 'string', description: 'Three to five words naming the task, for the activity row.' },
912
- prompt: { type: 'string', description: 'The full self-contained brief, including what to report back.' },
913
- },
914
- required: ['description', 'prompt'],
915
- },
916
- summarise: (input) => String(input.description || 'subagent'),
917
- async run(input, { cwd: runCwd }) {
918
- const { createPermissions } = await import('./permissions.js');
919
- const messages = [{ role: 'user', content: String(input.prompt || '') }];
920
- let answer = '';
921
- const result = await runAgent({
922
- client,
923
- messages,
924
- model,
925
- cwd: runCwd || cwd,
926
- permissions: permissions?.fork ? permissions.fork({ mode: 'plan', interactive: false }) : createPermissions({ mode: 'plan', interactive: false }),
927
- signal,
928
- tui: null,
929
- silent: true,
930
- subagents: false,
931
- // Read-only research across a codebase is many small reads; 20 cut it off
932
- // mid-search. The stuck detector is what catches a loop.
933
- maxIterations: 100,
934
- extraSystem: '\n\nYou are a subagent. You cannot ask questions and nobody will read your intermediate '
935
- + 'steps — only your final message. End with the findings themselves, with exact file paths and line '
936
- + 'numbers where they apply, not a description of how you looked.',
937
- onText: (text) => { answer += text; },
938
- });
939
- const trimmed = answer.trim();
940
- if (!trimmed) return `The subagent finished (${result.stopReason}) without reporting anything.`;
941
- return `${trimmed}\n\n[subagent: ${result.tools} tool calls, ${result.input + result.output} tokens]`;
942
- },
943
- };
944
- }
945
-
946
- /**
947
- * What of a tool's output is worth putting on screen.
948
- *
949
- * Not everything: a `read_file` the agent asked for is the agent's business, and
950
- * echoing 400 lines of it buries the reasoning that follows. A command's output, a
951
- * search's hits and a fetch's first lines are the ones a human reads over the agent's
952
- * shoulder — so those get a preview and the rest get a row.
953
- */
954
- function previewFor(name, output) {
955
- const text = String(output ?? '');
956
- if (!text.trim()) return '';
957
- switch (name) {
958
- case 'run_command':
959
- case 'background_read':
960
- return text;
961
- case 'search':
962
- case 'list_files':
963
- case 'web_search':
964
- return text;
965
- case 'web_fetch':
966
- return text.split('\n').slice(0, 8).join('\n');
967
- case 'read_file':
968
- case 'read_image':
969
- case 'write_file':
970
- case 'edit_file':
971
- case 'todo_write':
972
- return '';
973
- default:
974
- return text.length > 400 ? '' : text;
975
- }
976
- }
977
-
978
- function result(id, content, isError) {
979
- return { type: 'tool_result', tool_use_id: id, content: String(content), is_error: isError };
980
- }
1
+ // ─── The agent loop ─────────────────────────────────────────────────────────────
2
+ //
3
+ // Send the conversation to Jaitus, stream the reply, run any tools it asked for, append
4
+ // the results, and go again — until it stops asking for tools. That is the whole idea;
5
+ // everything else here is making it behave under real conditions.
6
+ //
7
+ // Notes on the parts that are easy to get wrong:
8
+ //
9
+ // • Streaming is parsed as server-sent events. Tool arguments arrive as `input_json_delta`
10
+ // fragments that only become valid JSON once concatenated, so they are buffered per
11
+ // content block and parsed at the end, never incrementally.
12
+ // • The assistant turn is appended EXACTLY as the model produced it, tool_use blocks
13
+ // included. Paraphrasing it breaks the pairing with tool_result and the next turn
14
+ // is rejected.
15
+ // • Every tool_use must get a tool_result, including ones that failed or were refused.
16
+ // A missing result is a malformed conversation, and the error text is also how the
17
+ // model learns to try something else.
18
+ // • A stuck detector, not a step count. A model caught in a loop would otherwise spend
19
+ // real money until someone notices — but a fixed cap of 40 steps also stopped every
20
+ // long, perfectly healthy task halfway and made the user type "continue". A run now
21
+ // ends when the model finishes, or when it repeats the same call with the same input
22
+ // STUCK_REPEATS times with no file changed in between (see `stuck` below). A very
23
+ // high backstop remains so no run is unbounded.
24
+
25
+ import { byName, toolSchemas, ToolError, readImageForModel } from './tools.js';
26
+ import { PermissionDenied } from './permissions.js';
27
+ import path from 'node:path';
28
+ import fs from 'node:fs';
29
+ import os from 'node:os';
30
+ import { loadContext, loadIgnore, CONTEXT_FILE } from '../project.js';
31
+ import { c, out, write, spinner, glyph } from '../ui.js';
32
+ import { discoverMcpTools } from '../mcp.js';
33
+ import { unifiedDiff } from '../diff.js';
34
+ import { fromResponse, fromGatewayBody, JaitusError } from '../errors.js';
35
+
36
+ const MAX_ITERATIONS = 1000;
37
+ const STUCK_REPEATS = 4;
38
+ const POLLING_TOOLS = new Set(['background_read', 'background_list']);
39
+
40
+ // Room for a long answer or a large file in one reply. The models behind Jaitus reason
41
+ // before they answer, and that reasoning is spent from the same budget — at 8192 a long
42
+ // answer, or any file of a few hundred lines, was cut off partway.
43
+ export const MAX_OUTPUT_TOKENS = 32000;
44
+
45
+ // A reply that stopped before it finished — the output limit, or a connection dropped
46
+ // somewhere between the provider and here — is continued, not presented as the answer.
47
+ // Bounded, so a model that never finishes cannot loop on the user's allowance.
48
+ export const MAX_CONTINUATIONS = 4;
49
+ export const CONTINUE_NOTE = 'Your previous reply was cut off before it finished (an output limit or a dropped connection). '
50
+ + 'Continue from exactly where it stopped: do not repeat or summarise what you already wrote, and do not mention this note. '
51
+ + 'If you were in the middle of a tool call, make that call again in full.';
52
+
53
+ // A request that failed before anything arrived (a network blip) is tried again this
54
+ // many times before it is reported.
55
+ const NETWORK_RETRIES = 2;
56
+
57
+ // Gateway answers that mean "not right now", not "no".
58
+ const TRANSIENT_CODES = new Set(['busy', 'unavailable', 'timeout', 'interrupted', 'internal', 'maintenance']);
59
+ const BUSY_RETRIES = 3;
60
+ const MAX_RETRY_WAIT_MS = 30_000;
61
+ // A blip (busy, unavailable, timed out) is retried quickly or not at all. The gateway's
62
+ // Retry-After for these is 30-60 s, and waiting that out three times kept the user on
63
+ // "Working…" for a minute and a half before saying anything — with every provider
64
+ // switched off, the "Jaitus is unavailable" that used to appear at once took 90 s.
65
+ const BLIP_MAX_WAIT_MS = 8_000;
66
+
67
+ /** How long to wait before retrying `e`, or null when it must not be retried. */
68
+ export function retryDelay(e, attempt = 0) {
69
+ const code = e?.code;
70
+ // The gateway knows when trying again cannot help (nothing is switched on to answer).
71
+ if (e?.noRetry) return null;
72
+ const hinted = Number(e?.retryAfter) > 0 ? Number(e.retryAfter) * 1000 : null;
73
+ if (code === 'rate_limited') {
74
+ // A short window is waited out; a cap that resets in hours is a limit, not a blip.
75
+ if (hinted == null) return 2000 * (attempt + 1);
76
+ return hinted <= MAX_RETRY_WAIT_MS ? hinted : null;
77
+ }
78
+ if (!TRANSIENT_CODES.has(code) || code === 'internal') return null;
79
+ return Math.min(BLIP_MAX_WAIT_MS, hinted ?? 2000 * 2 ** attempt);
80
+ }
81
+
82
+ function sleepUnlessAborted(ms, signal) {
83
+ return new Promise((resolve) => {
84
+ if (signal?.aborted) return resolve(false);
85
+ const t = setTimeout(() => { signal?.removeEventListener?.('abort', onAbort); resolve(true); }, ms);
86
+ const onAbort = () => { clearTimeout(t); resolve(false); };
87
+ signal?.addEventListener?.('abort', onAbort, { once: true });
88
+ });
89
+ }
90
+
91
+ /** opencode tool icon per tool, muted when done. ASCII twins included. */
92
+ const TOOL_MARKS = {
93
+ run_command: ['$', '$'], read_file: ['→', '>'], read_image: ['→', '>'],
94
+ list_files: ['→', '>'], write_file: ['←', '<'], edit_file: ['←', '<'],
95
+ search: ['✱', '*'], web_fetch: ['%', '%'], web_search: ['◈', 'o'],
96
+ background_run: ['◉', 'o'], todo_write: ['⚙', '*'],
97
+ };
98
+ const toolMark = (name) => {
99
+ const [uni, ascii] = TOOL_MARKS[name] || ['⚙', '*'];
100
+ return c.dim(glyph(uni, ascii));
101
+ };
102
+
103
+ export const SYSTEM_PROMPT = `You are Jaitus, a coding agent running in a developer's terminal, inside their project directory.
104
+
105
+ You work directly on this computer. Your tools read, search, create and edit the files in
106
+ the project and run shell commands in it, and you can fetch web pages. When the user asks
107
+ about their files, their code or their machine, use the tools and answer from what you
108
+ find — never say you cannot access local files, because you can.
109
+
110
+ How to work:
111
+ - Investigate before you change anything. Read the files you are about to edit; do not guess at their contents.
112
+ - Prefer edit_file over write_file for existing files. Keep changes minimal and in the style of the surrounding code.
113
+ - Run the project's own tests or build after a change when one exists, and report the real result — including when it fails.
114
+ - Use run_command for anything the shell does better: git, tests, package managers, linters.
115
+ - Never invent a file path, a function name, or a test result. If you are unsure, look.
116
+ - Every turn re-sends the whole conversation, so make independent reads, searches and
117
+ listings together in ONE turn rather than one per turn. Read the part of a large file you
118
+ need (offset/limit) rather than all of it.
119
+ - For current news or other web facts, report only details grounded in web_search or
120
+ web_fetch output. Include the supplied source URLs; never fill a missing headline or
121
+ date from memory.
122
+
123
+ How to talk:
124
+ - Be concise. This is a terminal, not a document.
125
+ - Say what you did and what happened, not what you are about to do.
126
+ - If something is blocked or you are missing information, say so plainly and ask.
127
+ - No preamble, no restating the question back.`;
128
+
129
+ /**
130
+ * The project's own briefing, appended to the system prompt.
131
+ *
132
+ * Fenced and labelled rather than concatenated, so instructions written by whoever owns
133
+ * the repo are visibly the repo's and cannot be mistaken for part of Jaitus's own rules.
134
+ */
135
+ /**
136
+ * Where the agent is: the facts it would otherwise guess at, and guess wrong.
137
+ *
138
+ * Without these, the model did not know which folder it was in, which OS its commands
139
+ * would run on (cmd.exe and /bin/sh disagree about almost everything), or today's date —
140
+ * and a model that does not know where it is tends to say it cannot see your files. The
141
+ * listing is the top level only, names not contents, and respects .jaitusignore.
142
+ */
143
+ export function environmentBlock(cwd, { now = new Date(), platform = process.platform } = {}) {
144
+ if (!cwd) return '';
145
+ const osName = { win32: 'Windows', darwin: 'macOS', linux: 'Linux' }[platform] || platform;
146
+ const shell = platform === 'win32' ? 'cmd.exe (run_command uses it)' : '/bin/sh (run_command uses it)';
147
+ // The repository may start above the working directory, and .git may be a file that
148
+ // points elsewhere (a worktree or submodule).
149
+ let branch = null;
150
+ for (let dir = cwd, i = 0; i < 40; i++) {
151
+ const dotGit = path.join(dir, '.git');
152
+ try {
153
+ let gitDir = dotGit;
154
+ if (fs.statSync(dotGit).isFile()) {
155
+ const m = /^gitdir:\s*(.+)$/m.exec(fs.readFileSync(dotGit, 'utf8'));
156
+ if (m) gitDir = path.resolve(dir, m[1].trim());
157
+ }
158
+ const head = fs.readFileSync(path.join(gitDir, 'HEAD'), 'utf8').trim();
159
+ branch = head.startsWith('ref: refs/heads/') ? head.slice(16) : `detached at ${head.slice(0, 7)}`;
160
+ break;
161
+ } catch { /* not here; look one level up */ }
162
+ const parent = path.dirname(dir);
163
+ if (parent === dir) break;
164
+ dir = parent;
165
+ }
166
+ let listing = '';
167
+ try {
168
+ const ignore = loadIgnore(cwd);
169
+ const SKIP = new Set(['.git', 'node_modules']);
170
+ const names = fs.readdirSync(cwd, { withFileTypes: true })
171
+ .filter((d) => !SKIP.has(d.name) && !ignore.matches(d.isDirectory() ? `${d.name}/` : d.name) && !ignore.matches(d.name))
172
+ .map((d) => (d.isDirectory() ? `${d.name}/` : d.name))
173
+ .sort((a, b) => (b.endsWith('/') - a.endsWith('/')) || a.localeCompare(b));
174
+ const MAX = 60;
175
+ listing = names.slice(0, MAX).join('\n') + (names.length > MAX ? `\n… and ${names.length - MAX} more` : '');
176
+ } catch { /* unreadable: the tools will say why */ }
177
+ return `
178
+
179
+ <environment>
180
+ Working directory: ${cwd}
181
+ Platform: ${osName} (${os.release()})
182
+ Shell: ${shell}
183
+ Today: ${now.toISOString().slice(0, 10)}
184
+ Git: ${branch ? `yes, branch ${branch}` : 'no repository at this folder'}
185
+ Top level of the working directory:
186
+ ${listing || '(empty)'}
187
+ </environment>`;
188
+ }
189
+
190
+ export function systemPrompt(cwd) {
191
+ const project = cwd ? loadContext(cwd) : null;
192
+ const base = `${SYSTEM_PROMPT}${environmentBlock(cwd)}`;
193
+ if (!project) return base;
194
+ return `${base}
195
+
196
+ The project you are working in ships a ${CONTEXT_FILE} with notes for you. Follow it where
197
+ it applies, and prefer what it says over your own assumptions about this codebase.
198
+
199
+ <${CONTEXT_FILE}>
200
+ ${project}
201
+ </${CONTEXT_FILE}>`;
202
+ }
203
+
204
+ /**
205
+ * Every earlier image is sent again with every request, and the gateway accepts at most
206
+ * 10 MB per request — three or four screenshots into a conversation, every turn failed.
207
+ * The newest images are kept up to IMAGE_BUDGET bytes of base64; older ones are replaced
208
+ * by a line saying one was there. The history itself is not changed.
209
+ */
210
+ /**
211
+ * Make a history well-formed after any interruption: every assistant tool_use has a
212
+ * tool_result in the next message. A run stopped by Esc, a failed permission prompt or
213
+ * a gateway error partway through could leave a tool call with no result — and both
214
+ * provider formats reject that, so every later request in the session failed. Missing
215
+ * results are filled in as "not run"; an assistant turn that ends the history with tool
216
+ * calls gets its results appended.
217
+ */
218
+ export function repairToolPairs(messages) {
219
+ for (let i = 0; i < messages.length; i++) {
220
+ const m = messages[i];
221
+ if (m?.role !== 'assistant' || !Array.isArray(m.content)) continue;
222
+ const ids = m.content.filter((b) => b?.type === 'tool_use').map((b) => b.id);
223
+ if (!ids.length) continue;
224
+ const next = messages[i + 1];
225
+ const have = new Set(next?.role === 'user' && Array.isArray(next.content)
226
+ ? next.content.filter((b) => b?.type === 'tool_result').map((b) => b.tool_use_id) : []);
227
+ const missing = ids.filter((id) => !have.has(id));
228
+ if (!missing.length) continue;
229
+ const filled = missing.map((id) => result(id, 'Not run: the task was interrupted before this step.', true));
230
+ if (next?.role === 'user') {
231
+ const content = Array.isArray(next.content) ? next.content : [{ type: 'text', text: String(next.content ?? '') }];
232
+ const results = content.filter((b) => b?.type === 'tool_result');
233
+ const rest = content.filter((b) => b?.type !== 'tool_result');
234
+ messages[i + 1] = { ...next, content: [...results, ...filled, ...rest] };
235
+ } else {
236
+ messages.splice(i + 1, 0, { role: 'user', content: filled });
237
+ }
238
+ }
239
+ return messages;
240
+ }
241
+
242
+ // ── Keeping the conversation small ────────────────────────────────────────────
243
+ //
244
+ // Every request re-sends the whole conversation, and in real use that re-send — cached,
245
+ // but not free — was ~75% of what a session cost: 109 requests averaged 46k tokens of
246
+ // context each, most of it tool output from many steps ago (a file read at step 3 is
247
+ // still paid for at step 60). This is the documented "clear old tool results" strategy
248
+ // (Anthropic's context editing, clear_tool_uses; Claude Code's micro-compaction): once
249
+ // the conversation passes TRIM_AT, the outputs of all but the KEEP_RECENT latest tool
250
+ // calls are replaced with a line saying they were cleared and can be re-run. It clears
251
+ // at least TRIM_AT_LEAST at a time, because each clearing breaks the prompt cache once
252
+ // — clearing a little on every step would cost more than it saves. Small results (a
253
+ // "file written", an exit code) are kept: they carry meaning and cost nothing.
254
+ // 100k was the documented default for 200k-window models. Here the window is up to 1M, so
255
+ // that ceiling was rarely near: production requests averaged 53k tokens and two thirds of
256
+ // all input tokens came from requests over 60k. At 40k a clearing (one cache break, ~17
257
+ // credits on glm-5.3-flash) pays for itself within about ten later requests.
258
+ export const TRIM_AT = 40_000; // estimated tokens of conversation
259
+ export const KEEP_RECENT = 6; // latest tool results always kept whole
260
+ export const TRIM_AT_LEAST = 12_000; // estimated tokens a clearing must free
261
+ const TRIM_MIN_CHARS = 1_500; // results shorter than this are never cleared
262
+ const CLEARED = '[Output cleared';
263
+
264
+ const blockChars = (b) => {
265
+ if (b?.type === 'text') return (b.text || '').length;
266
+ if (b?.type === 'tool_use') return JSON.stringify(b.input || {}).length;
267
+ if (b?.type === 'tool_result') return typeof b.content === 'string' ? b.content.length : JSON.stringify(b.content || '').length;
268
+ return 0;
269
+ };
270
+ export function conversationTokens(messages) {
271
+ let chars = 0;
272
+ for (const m of messages || []) chars += typeof m?.content === 'string' ? m.content.length : (Array.isArray(m?.content) ? m.content.reduce((n, b) => n + blockChars(b), 0) : 0);
273
+ return Math.ceil(chars / 4);
274
+ }
275
+
276
+ /**
277
+ * Clear old tool outputs in place when the conversation is large. Returns how many
278
+ * estimated tokens were freed (0 when nothing was worth clearing).
279
+ */
280
+ export function clearOldToolResults(messages, { at = TRIM_AT, keep = KEEP_RECENT, atLeast = TRIM_AT_LEAST } = {}) {
281
+ if (conversationTokens(messages) < at) return 0;
282
+ const names = new Map();
283
+ for (const m of messages) {
284
+ if (m?.role === 'assistant' && Array.isArray(m.content)) for (const b of m.content) if (b?.type === 'tool_use') names.set(b.id, b.name);
285
+ }
286
+ const results = [];
287
+ for (const m of messages) {
288
+ if (m?.role !== 'user' || !Array.isArray(m.content)) continue;
289
+ m.content.forEach((b, i) => { if (b?.type === 'tool_result') results.push({ m, i, b }); });
290
+ }
291
+ const candidates = results.slice(0, Math.max(0, results.length - keep)).filter(({ b }) => {
292
+ const text = typeof b.content === 'string' ? b.content : '';
293
+ return text.length >= TRIM_MIN_CHARS && !text.startsWith(CLEARED);
294
+ });
295
+ const freed = Math.ceil(candidates.reduce((n, { b }) => n + blockChars(b), 0) / 4);
296
+ if (freed < atLeast) return 0;
297
+ for (const { m, i, b } of candidates) {
298
+ const chars = blockChars(b);
299
+ const name = names.get(b.tool_use_id) || 'this tool';
300
+ m.content[i] = { ...b, content: `${CLEARED}: ${name} returned ${chars.toLocaleString()} characters here, removed to keep the conversation small. Run it again if you need it.]` };
301
+ }
302
+ return freed;
303
+ }
304
+
305
+ export const IMAGE_BUDGET = 6_000_000;
306
+ export function withinImageBudget(messages, budget = IMAGE_BUDGET) {
307
+ let used = 0;
308
+ let changed = false;
309
+ const out = messages.slice();
310
+ for (let i = out.length - 1; i >= 0; i--) {
311
+ const m = out[i];
312
+ if (!Array.isArray(m?.content) || !m.content.some((b) => b?.type === 'image')) continue;
313
+ // Walk this message's blocks newest-last, so within a message the later images win.
314
+ const kept = new Array(m.content.length);
315
+ for (let j = m.content.length - 1; j >= 0; j--) {
316
+ const b = m.content[j];
317
+ if (b?.type !== 'image') { kept[j] = b; continue; }
318
+ const size = String(b.source?.data || '').length;
319
+ if (used + size <= budget) { used += size; kept[j] = b; continue; }
320
+ kept[j] = { type: 'text', text: '[An image was attached here earlier; it is no longer included, to keep the request small.]' };
321
+ changed = true;
322
+ }
323
+ out[i] = { ...m, content: kept };
324
+ }
325
+ return changed ? out : messages;
326
+ }
327
+
328
+ /** One streamed request. Returns { content, stopReason, usage }. */
329
+ export async function streamTurn({ client, messages, model, system, tools, onText, onToolStart, signal, maxTokens = MAX_OUTPUT_TOKENS }) {
330
+ const res = await client.messages({
331
+ model,
332
+ max_tokens: maxTokens,
333
+ system: system || SYSTEM_PROMPT,
334
+ tools,
335
+ messages: withinImageBudget(messages),
336
+ stream: true,
337
+ }, { signal });
338
+
339
+ if (!res.ok) throw await fromResponse(res);
340
+ const requestId = res.headers?.get?.('x-request-id') || '';
341
+
342
+ // Blocks are keyed by the index the stream gives them, because they interleave.
343
+ const blocks = new Map();
344
+ let stopReason = null;
345
+ // Whether the stream ended the way a finished message ends. A stream that just stops
346
+ // — no message_stop, no stop reason — was cut, and used to be taken as a complete,
347
+ // silent answer: the "it stops midway for no reason" bug.
348
+ let sawStop = false;
349
+ // `credits` is what the gateway counted against the plan for this request — real cost,
350
+ // the one unit the plan, the dashboard and this CLI all show.
351
+ const usage = { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, credits: 0 };
352
+ // Usage can arrive at the START of a stream (Anthropic-format providers) or only at
353
+ // the END, in message_delta (OpenAI-format providers, translated by the gateway —
354
+ // their usage exists only once generation finishes). Reading input from message_start
355
+ // alone recorded every OpenAI-format turn as 0 input tokens.
356
+ const takeUsage = (u) => {
357
+ if (!u) return;
358
+ for (const k of ['input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens', 'credits']) {
359
+ if (u[k] != null && Number.isFinite(Number(u[k]))) usage[k] = Number(u[k]);
360
+ }
361
+ };
362
+ let buffer = '';
363
+ // A stream that never ends must die here, not in GC hell: the buffer below grows
364
+ // with every chunk and each arrival re-splits all of it, so a runaway provider
365
+ // (twelve silent minutes and counting, seen in the wild) grows memory
366
+ // quadratically until V8 falls over near 4 GB. Past the cap the turn ends as a
367
+ // cut (continued from what arrived) or, with nothing received, as a retryable
368
+ // timeout — never as a crash.
369
+ const MAX_STREAM_BUFFER = 8 * 1024 * 1024;
370
+ // A failure reported INSIDE the stream, after a 200. Recorded here and thrown after
371
+ // the parse below: handle() runs inside a try whose catch exists to skip keepalives,
372
+ // so throwing from it would be swallowed — which is how a mid-stream error used to
373
+ // end an answer halfway with no explanation at all.
374
+ let streamError = null;
375
+
376
+ const handle = (event) => {
377
+ switch (event.type) {
378
+ case 'error':
379
+ streamError = fromGatewayBody(200, event, { requestId });
380
+ break;
381
+ case 'message_start':
382
+ takeUsage(event.message?.usage);
383
+ break;
384
+ case 'content_block_start': {
385
+ const b = event.content_block || {};
386
+ blocks.set(event.index, b.type === 'tool_use'
387
+ ? { type: 'tool_use', id: b.id, name: b.name, json: '' }
388
+ : { type: 'text', text: '' });
389
+ if (b.type === 'tool_use') onToolStart?.(b.name);
390
+ break;
391
+ }
392
+ case 'content_block_delta': {
393
+ const b = blocks.get(event.index);
394
+ if (!b) break;
395
+ const d = event.delta || {};
396
+ if (d.type === 'text_delta' && typeof d.text === 'string') {
397
+ b.text += d.text;
398
+ onText?.(d.text);
399
+ } else if (d.type === 'input_json_delta' && typeof d.partial_json === 'string') {
400
+ b.json += d.partial_json;
401
+ }
402
+ break;
403
+ }
404
+ case 'message_delta':
405
+ if (event.delta?.stop_reason) stopReason = event.delta.stop_reason;
406
+ takeUsage(event.usage);
407
+ break;
408
+ case 'message_stop':
409
+ sawStop = true;
410
+ break;
411
+ default:
412
+ break;
413
+ }
414
+ };
415
+
416
+ // fetch() yields Uint8Array, NOT a Node Buffer. `uint8.toString('utf8')` ignores the
417
+ // argument and returns "101,118,101,…" — comma-joined byte values — so the whole
418
+ // stream parses to nothing and the agent appears to answer with silence.
419
+ //
420
+ // TextDecoder with stream:true is also the only correct choice for a different reason:
421
+ // a multi-byte character can be split across two chunks, and decoding each chunk
422
+ // independently would corrupt it. The decoder holds the partial bytes until complete.
423
+ const decoder = new TextDecoder();
424
+
425
+ let cut = false;
426
+ try {
427
+ for await (const chunk of res.body) {
428
+ buffer += decoder.decode(chunk, { stream: true });
429
+ if (buffer.length > MAX_STREAM_BUFFER) {
430
+ // Breaking the loop releases the reader (the socket is cancelled), so the
431
+ // runaway stops arriving too — not just stops being kept.
432
+ if (!blocks.size) throw new JaitusError({ code: 'timeout', status: 504, message: 'Jaitus took too long to respond. Try again.' });
433
+ cut = true;
434
+ break;
435
+ }
436
+ const frames = buffer.split('\n\n');
437
+ buffer = frames.pop() || '';
438
+ for (const frame of frames) {
439
+ for (const line of frame.split('\n')) {
440
+ if (!line.startsWith('data:')) continue;
441
+ const raw = line.slice(5).trim();
442
+ if (!raw || raw === '[DONE]') continue;
443
+ try { handle(JSON.parse(raw)); } catch { /* keepalive or partial */ }
444
+ }
445
+ }
446
+ if (streamError) break;
447
+ }
448
+ } catch (e) {
449
+ // Esc is the only abort that reads as an interruption. Any OTHER AbortError —
450
+ // the request timeout in api.js firing mid-body, a dropped socket — is a `timeout`
451
+ // JaitusError so the agent loop backs off and retries it (TRANSIENT_CODES) instead
452
+ // of reporting "Interrupted." for something the user never stopped.
453
+ if (signal?.aborted) throw e;
454
+ // The connection dropped (or went silent) mid-answer. What arrived is kept and
455
+ // continued from; with nothing received yet, the caller retries the request.
456
+ if (!blocks.size) {
457
+ if (e?.name === 'AbortError') throw new JaitusError({ code: 'timeout', status: 504, message: 'Jaitus took too long to respond. Try again.' });
458
+ throw e;
459
+ }
460
+ cut = true;
461
+ }
462
+ if (streamError) {
463
+ // The provider dropped out after part of the answer had arrived. What arrived is
464
+ // kept and continued from, exactly as when OUR connection drops — it used to be
465
+ // thrown away with a "Something went wrong" and the task stopped.
466
+ if (blocks.size && TRANSIENT_CODES.has(streamError.code)) cut = true;
467
+ else throw streamError;
468
+ }
469
+ if (cut || (!sawStop && !stopReason)) stopReason = 'cut';
470
+
471
+ const content = [...blocks.entries()]
472
+ .sort((a, b) => a[0] - b[0])
473
+ .map(([, b]) => {
474
+ if (b.type !== 'tool_use') return { type: 'text', text: b.text };
475
+ let input = {};
476
+ // The fragments only form valid JSON once joined. An empty string is a
477
+ // no-argument call, which is legitimate.
478
+ try { input = b.json.trim() ? JSON.parse(b.json) : {}; } catch { input = { __parse_error: b.json }; }
479
+ return { type: 'tool_use', id: b.id, name: b.name, input };
480
+ })
481
+ .filter((b) => b.type !== 'text' || b.text.length);
482
+
483
+ return { content, stopReason, usage };
484
+ }
485
+
486
+ /**
487
+ * Run one user request to completion.
488
+ * `messages` is mutated so the caller keeps the conversation across turns.
489
+ */
490
+ export async function runAgent({
491
+ client, messages, model, cwd, permissions, signal, tui = null,
492
+ extraSystem = '', maxIterations = MAX_ITERATIONS, silent = false,
493
+ onText = null, onEvent = null, systemOverride = null, journal = null,
494
+ subagents = true, onContext = null, trimContext = true,
495
+ }) {
496
+ // Loop detection. A call is identified by its tool, its input, and how many files had
497
+ // been changed when it was made — so edit → test → edit → test is progress (the count
498
+ // moves), while reading the same file over and over is not.
499
+ let changes = 0;
500
+ const repeats = new Map();
501
+ // input = fresh input, cached = prompt-cache reads (billed far cheaper), and
502
+ // context = the real size of the last prompt sent, as the provider counted it.
503
+ const totals = { input: 0, cached: 0, output: 0, tools: 0, context: 0, credits: 0 };
504
+ // Read once per request, not per turn: an agent that rewrites JAITUS.md mid-task
505
+ // should not change the rules it is being judged by halfway through.
506
+ // The model is told when it is in plan mode — it used to find out only by having its
507
+ // writes refused, after it had already tried them.
508
+ const planNote = permissions?.mode === 'plan'
509
+ ? '\n\nPLAN MODE is on: you can read, list, search and fetch, but nothing you write and no command you run will be carried out. Investigate, then answer with a concrete plan (files, changes, order, risks). The user switches modes when they want it done.'
510
+ : '';
511
+ const system = `${systemOverride || systemPrompt(cwd)}${extraSystem}${planNote}`;
512
+ const mcp = await discoverMcpTools(cwd).catch(() => []);
513
+ // Subagents are offered only to the top-level agent. A subagent that can spawn
514
+ // subagents is an unbounded fan-out on a metered gateway, and the depth that would
515
+ // actually help — one — is the depth that is safe.
516
+ const subagent = subagents ? makeSubagentTool({ client, model, cwd, signal, tui, permissions }) : null;
517
+ const availableTools = [
518
+ ...toolSchemas(),
519
+ ...(subagent ? [{ name: subagent.name, description: subagent.description, input_schema: subagent.input_schema }] : []),
520
+ ...mcp.map((tool) => ({ name: tool.name, description: tool.description, input_schema: tool.input_schema })),
521
+ ];
522
+ const toolMap = {
523
+ ...byName,
524
+ ...(subagent ? { [subagent.name]: subagent } : {}),
525
+ ...Object.fromEntries(mcp.map((tool) => [tool.name, tool])),
526
+ };
527
+
528
+ let continuations = 0;
529
+ let networkRetries = 0;
530
+ let busyRetries = 0;
531
+ let planNoticeShown = false;
532
+ // True while the previous reply is being continued: its text is still on screen as
533
+ // one live message, and the continuation must extend it rather than start another.
534
+ let continuing = false;
535
+
536
+ for (let iteration = 0; iteration < maxIterations; iteration++) {
537
+ // Old tool outputs are cleared once the conversation is large (see TRIM_AT) — in
538
+ // place, so the saved session shrinks too and the cache stays warm afterwards.
539
+ if (trimContext) {
540
+ const freed = clearOldToolResults(messages);
541
+ if (freed && tui?.notice) tui.notice(`Cleared old tool output (~${Math.round(freed / 1000)}k tokens) to keep each request small.`, 'info');
542
+ }
543
+ let streamed = false;
544
+ const spin = tui ? { stop: () => {}, update: () => {} } : (silent ? { stop: () => {}, update: () => {} } : spinner(glyph('Working…', 'Working...')));
545
+ if (tui) tui.setStatus(`${c.brand(glyph('•', '*'))} ${c.brand(glyph('Working…', 'Working...'))}`);
546
+ // Codex-style assistant cell: the • leads the turn the way › leads the
547
+ // user's. In scrollback the marker lands before the first streamed chunk.
548
+ let turnVoiced = false;
549
+
550
+ let turn;
551
+ try {
552
+ turn = await streamTurn({
553
+ client,
554
+ messages,
555
+ model,
556
+ system,
557
+ tools: availableTools,
558
+ signal,
559
+ onText: (text) => {
560
+ onText?.(text);
561
+ if (!streamed) {
562
+ spin.stop(); streamed = true;
563
+ if (tui) tui.clearStatus();
564
+ else if (!silent && !onText) { out(''); write(`${c.dim(glyph('•', '*'))} `); }
565
+ }
566
+ if (tui) {
567
+ // The transcript entry draws its own speaker mark now, so the loop no
568
+ // longer prepends one — two marks on the same line was the tell that the
569
+ // renderer and the loop each thought they owned the lead glyph.
570
+ turnVoiced = true;
571
+ tui.printStream(text);
572
+ } else if (!silent && !onText) write(text);
573
+ },
574
+ onToolStart: () => spin.update(glyph('Working…', 'Working...')),
575
+ });
576
+ } catch (e) {
577
+ spin.stop();
578
+ // Esc during streaming: stop mid-sentence, keep what landed, don't treat it
579
+ // as an error. The partial text is already on screen.
580
+ // A network failure before anything arrived: try again, briefly, before reporting.
581
+ // Anything the gateway itself refused (a limit, a sign-in problem) has a status and
582
+ // is reported at once — retrying it would only repeat the refusal.
583
+ if (!(signal?.aborted || e?.name === 'AbortError') && !e?.status && networkRetries < NETWORK_RETRIES) {
584
+ networkRetries += 1;
585
+ await new Promise((r) => setTimeout(r, 800 * networkRetries));
586
+ iteration -= 1;
587
+ continue;
588
+ }
589
+ // Busy, rate-limited for a moment, unavailable, timed out: the gateway says to
590
+ // back off and retry (it sends Retry-After), and nothing used to — one provider 429
591
+ // ended a task halfway. A limit (plan, free tier, a daily cap hours away) or a
592
+ // sign-in problem is never retried.
593
+ const wait = retryDelay(e, busyRetries);
594
+ if (!(signal?.aborted || e?.name === 'AbortError') && wait != null && busyRetries < BUSY_RETRIES) {
595
+ busyRetries += 1;
596
+ const secs = Math.max(1, Math.round(wait / 1000));
597
+ const what = e?.code === 'busy' || e?.code === 'rate_limited' ? 'Jaitus is busy' : (e?.title || 'Jaitus did not answer');
598
+ if (tui?.setStatus) tui.setStatus(`${c.yellow('!')} ${c.dim(`${what} — trying again in ${secs}s (${busyRetries} of ${BUSY_RETRIES})`)}`);
599
+ const ok = await sleepUnlessAborted(wait, signal);
600
+ tui?.clearStatus?.();
601
+ if (!ok) return { ...totals, stopReason: 'interrupted' };
602
+ iteration -= 1;
603
+ continue;
604
+ }
605
+ if (signal?.aborted || e?.name === 'AbortError') {
606
+ if (tui) tui.flushStream();
607
+ if (streamed && !silent) out('');
608
+ if (tui?.notice) tui.notice('Interrupted.', 'warn');
609
+ else if (tui) tui.print(c.yellow(` ${glyph('⏹', '[x]')} Interrupted.`));
610
+ else if (!silent) out(c.yellow(` ${glyph('⏹', '[x]')} Interrupted.`));
611
+ return { ...totals, stopReason: 'interrupted' };
612
+ }
613
+ throw e;
614
+ }
615
+ spin.stop();
616
+ networkRetries = 0;
617
+ busyRetries = 0;
618
+ // A reply the provider's content filter stopped is cut short; say so rather than
619
+ // letting half an answer pass for the whole one.
620
+ if (turn.stopReason === 'refusal') {
621
+ const why = 'The answer was stopped partway by a content filter. Rephrasing the request usually gets past it.';
622
+ if (tui?.notice) tui.notice(why, 'warn');
623
+ else if (!silent) out(c.yellow(` ${why}`));
624
+ }
625
+ const incomplete = turn.stopReason === 'max_tokens' || turn.stopReason === 'cut';
626
+ // A tool call cut off halfway has JSON that is not whole; it is dropped and the model
627
+ // is asked to make it again.
628
+ if (incomplete) turn.content = turn.content.filter((b) => b.type !== 'tool_use' || b.input?.__parse_error === undefined);
629
+ const willContinue = incomplete && continuations < MAX_CONTINUATIONS && !turn.content.some((b) => b.type === 'tool_use');
630
+ if (!willContinue) {
631
+ if (tui) tui.flushStream();
632
+ if (streamed || continuing) { if (tui) tui.printStream('\n'); else if (!silent && !onText) out(''); }
633
+ }
634
+
635
+ totals.input += turn.usage.input_tokens || 0;
636
+ totals.cached += (turn.usage.cache_read_input_tokens || 0) + (turn.usage.cache_creation_input_tokens || 0);
637
+ totals.output += turn.usage.output_tokens || 0;
638
+ totals.credits += Number(turn.usage.credits) || 0;
639
+ const promptSize = (turn.usage.input_tokens || 0) + (turn.usage.cache_read_input_tokens || 0) + (turn.usage.cache_creation_input_tokens || 0);
640
+ if (promptSize) {
641
+ totals.context = promptSize + (turn.usage.output_tokens || 0);
642
+ // The header's context meter moves after every step. It used to be told only when
643
+ // the whole run returned, so through a long task — and after one stopped with
644
+ // Esc or an error — it sat at 0.
645
+ try { onContext?.(totals.context); } catch { /* the meter never breaks a turn */ }
646
+ }
647
+ // The work bar's live count. It was declared and drawn but never fed, so it never
648
+ // appeared; now it moves after every step of the turn.
649
+ tui?.setTurnTokens?.(totals.input + totals.cached + totals.output, totals.credits);
650
+
651
+ if (willContinue) {
652
+ continuations += 1;
653
+ continuing = true;
654
+ const text = turn.content.filter((b) => b.type === 'text');
655
+ if (text.length) messages.push({ role: 'assistant', content: text });
656
+ messages.push({ role: 'user', content: [{ type: 'text', text: CONTINUE_NOTE }] });
657
+ onEvent?.({ type: 'continued', reason: turn.stopReason, count: continuations });
658
+ continue;
659
+ }
660
+ continuing = false;
661
+ if (incomplete && !turn.content.some((b) => b.type === 'tool_use')) {
662
+ // Still unfinished after every continuation: say so, rather than let a partial
663
+ // answer pass as a whole one.
664
+ const why = 'The reply was still unfinished after several continuations. Ask for the rest, or narrow the request.';
665
+ if (tui?.notice) tui.notice(why, 'warn');
666
+ else if (!silent) out(c.yellow(` ${why}`));
667
+ }
668
+
669
+ // Append the assistant turn verbatim — tool_use blocks and all.
670
+ messages.push({ role: 'assistant', content: turn.content });
671
+ onEvent?.({ type: 'assistant_turn', content: turn.content, usage: turn.usage, iteration });
672
+
673
+ const calls = turn.content.filter((b) => b.type === 'tool_use');
674
+
675
+ // Plan mode: read-only by decree. The turn's text still lands (the model can think
676
+ // out loud), but nothing on disk or in a shell is allowed to happen.
677
+ // web_fetch is not a write: in plan mode it goes to its own prompt instead of ending the turn.
678
+ // Plan mode: a write or command gets a "not run — plan instead" result, the reads in
679
+ // the same batch still run, and the turn — with the plan the model just wrote — stays
680
+ // in the history. It used to pop the whole turn, so "ok, do it" found no plan to do.
681
+ const planBlocked = permissions.mode === 'plan'
682
+ ? new Set(calls.filter((call) => toolMap[call.name]?.needsPermission && !toolMap[call.name]?.network).map((call) => call.id))
683
+ : new Set();
684
+ if (planBlocked.size && !planNoticeShown) {
685
+ planNoticeShown = true;
686
+ const text = 'Plan mode — writes and commands are not run; the agent can still read. Shift+Tab (or /code) to let it act.';
687
+ if (tui?.notice) tui.notice(text, 'warn');
688
+ else if (!silent) out(c.yellow(` ${glyph('⏸', '[II]')} ${text}`));
689
+ }
690
+
691
+ // Esc pressed while the model was mid-turn: stop before any more tools run.
692
+ if (signal?.aborted) {
693
+ if (!silent) out(c.yellow(` ${glyph('⏹', '[x]')} Interrupted.`));
694
+ return { ...totals, stopReason: 'interrupted' };
695
+ }
696
+
697
+ if (!calls.length) { tui?.bellIfAway?.(); return { ...totals, stopReason: turn.stopReason }; }
698
+
699
+ // The same call, same input, nothing changed since: STUCK_REPEATS times is a loop.
700
+ // Checked before running, so the repeat that proves it is not paid for again.
701
+ const stuckOn = calls.find((call) => {
702
+ // Polling is supposed to repeat: background_read's own description says "poll
703
+ // this", and four polls of a starting dev server are not a loop.
704
+ if (POLLING_TOOLS.has(call.name)) return false;
705
+ const key = `${changes}\u0000${call.name}\u0000${JSON.stringify(call.input ?? null)}`;
706
+ const n = (repeats.get(key) || 0) + 1;
707
+ repeats.set(key, n);
708
+ return n >= STUCK_REPEATS;
709
+ });
710
+ if (stuckOn) {
711
+ // Every tool_use still gets a result — the model is told why these did not run, so
712
+ // after "continue" it does not simply make the same call a fifth time.
713
+ messages.push({ role: 'user', content: calls.map((call) => result(call.id,
714
+ `Not run: this exact call was already made ${STUCK_REPEATS - 1} times with no file changed since, and the run was stopped as stuck. Do something different.`, true)) });
715
+ const label = toolMap[stuckOn.name]?.summarise ? toolMap[stuckOn.name].summarise(stuckOn.input) : stuckOn.name;
716
+ const why = `Stopped: it made the same ${stuckOn.name} call (${String(label).slice(0, 60)}) ${STUCK_REPEATS} times without changing anything, so it looks stuck. Tell it what to do differently, or say "continue".`;
717
+ if (tui?.notice) tui.notice(why, 'warn');
718
+ else if (!silent) out(c.yellow(`\n${why}`));
719
+ tui?.bellIfAway?.();
720
+ return { ...totals, stopReason: 'stuck' };
721
+ }
722
+
723
+ // Every call gets a result, whatever happened.
724
+ const results = [];
725
+ for (const call of calls) {
726
+ const tool = toolMap[call.name];
727
+ totals.tools += 1;
728
+
729
+ // Esc during a batch stops the REST of it. The check used to run once before the
730
+ // batch, so after Esc the remaining calls still prompted and ran.
731
+ if (signal?.aborted) {
732
+ results.push(result(call.id, 'Not run: the user stopped the task.', true));
733
+ continue;
734
+ }
735
+ if (planBlocked.has(call.id)) {
736
+ results.push(result(call.id, 'Not run: plan mode is on, so nothing is written and no command runs. Finish investigating with read-only tools, then present your plan as your answer — the user will switch modes when they want it carried out.', true));
737
+ continue;
738
+ }
739
+ if (!tool) {
740
+ results.push(result(call.id, `No such tool: ${call.name}`, true));
741
+ continue;
742
+ }
743
+ if (call.input?.__parse_error !== undefined) {
744
+ results.push(result(call.id, 'Your tool arguments were not valid JSON. Send them again.', true));
745
+ continue;
746
+ }
747
+
748
+ const label = tool.summarise ? tool.summarise(call.input) : call.name;
749
+ const activityId = tui?.recordActivity?.({ name: call.name, label, status: 'waiting' });
750
+ // One row per call, mutated in place through waiting → running → ok/failed. The
751
+ // previous surface printed a fresh line per state change, so a session with
752
+ // twenty tool calls scrolled sixty lines of near-duplicates past the answer.
753
+ const row = tui?.tool?.({ name: call.name, label, status: 'waiting', file: call.input?.path });
754
+
755
+ try {
756
+ await permissions.request(tool, call.input, { cwd, tui, signal });
757
+ } catch (e) {
758
+ if (e instanceof PermissionDenied) {
759
+ const declined = ` ${c.red(glyph('✗', 'x'))} ${c.dim('declined')}`;
760
+ if (row) row.update({ status: 'denied', error: e.comment ? `declined — "${e.comment}"` : 'declined' });
761
+ else if (tui) tui.print(declined);
762
+ else if (!silent) out(declined);
763
+ tui?.recordActivity?.({ id: activityId, name: call.name, label, status: 'failed', detail: 'Permission declined by user.' });
764
+ // Told to the model, not just the user: it needs to know to stop trying and
765
+ // propose something else instead. A decision comment rides along verbatim.
766
+ // A run that could not ask (one-shot, a script) says so — "the user declined"
767
+ // was untrue there, and hid the hint about --yes from everyone.
768
+ if (e.headless) totals.denied = (totals.denied || 0) + 1;
769
+ const note = e.headless
770
+ ? `Not permitted in this run: ${e.message} Do not retry it; finish what you can without it and say what was left undone.`
771
+ : e.comment
772
+ ? `The user declined this action with this instruction, which overrides your plan: "${e.comment}". Do not retry the declined action; follow the instruction instead.`
773
+ : `The user declined this action. Do not retry it; ask what they would prefer.`;
774
+ results.push(result(call.id, note, true));
775
+ continue;
776
+ }
777
+ throw e;
778
+ }
779
+
780
+ tui?.recordActivity?.({ id: activityId, name: call.name, label, status: 'running' });
781
+ row?.update({ status: 'running' });
782
+ const spin2 = tui ? { stop: () => {} } : (silent ? { stop: () => {} } : spinner(`${call.name}: ${label}`.slice(0, 100)));
783
+ const toolStarted = Date.now();
784
+ // Live elapsed ticker on the status row while a tool runs (1s cadence,
785
+ // silent when animations are off — progress you can watch, not noise).
786
+ let elapsedTimer = null;
787
+ const stopElapsed = () => { if (elapsedTimer) { clearInterval(elapsedTimer); elapsedTimer = null; } };
788
+ if (tui && tui.animationsOn?.()) {
789
+ const toolStatus = () => `${c.brand(glyph('•', '*'))} ${call.name}: ${c.dim(String(label).slice(0, 60))} ${c.dim(`· ${((Date.now() - toolStarted) / 1000).toFixed(0)}s`)}`;
790
+ tui.setStatus(toolStatus());
791
+ elapsedTimer = setInterval(() => tui.setStatus(toolStatus()), 1000);
792
+ if (elapsedTimer.unref) elapsedTimer.unref();
793
+ } else if (tui) {
794
+ tui.setStatus(`${c.brand(glyph('•', '*'))} ${call.name}: ${c.dim(String(label).slice(0, 70))}`);
795
+ }
796
+ try {
797
+ // Rewind safety net: before the agent overwrites or creates a file, remember
798
+ // what was there. write_file/edit_file take their targets from the input;
799
+ // run_command's target is unknowable, so its calls snapshot the whole
800
+ // tracked set instead (cheap: only files already touched this session).
801
+ if (journal && ['write_file', 'edit_file'].includes(call.name) && call.input?.path) {
802
+ try { journal.snapshotFile(path.resolve(cwd, String(call.input.path))); } catch { /* path never existed */ }
803
+ } else if (journal && call.name === 'run_command') {
804
+ journal.snapshotTracked(cwd);
805
+ }
806
+ const output = await tool.run(call.input, { cwd, signal });
807
+ spin2.stop(); stopElapsed();
808
+ const took = ((Date.now() - toolStarted) / 1000).toFixed(1);
809
+ // opencode tool icons: $ shell, ← edits, → reads, ✱ search,
810
+ // % fetch, ◈ websearch, ◉ subagents, ⚙ everything else. Done
811
+ // rows rest muted; failures stay red.
812
+ const done = ` ${toolMark(call.name)} ${c.dim(String(label).slice(0, 100))} ${c.dim(`· ${took}s`)}`;
813
+ if (row) {
814
+ tui.clearStatus();
815
+ // The whole output travels with the row (capped), so a click can open it.
816
+ row.update({ status: 'ok', took, preview: previewFor(call.name, output), full: String(output ?? '').slice(0, 200_000) });
817
+ } else if (tui) { tui.clearStatus(); tui.print(done, 'tool'); } else if (!silent) out(done);
818
+ tui?.recordActivity?.({ id: activityId, name: call.name, label, status: 'ok', detail: String(output).slice(0, 2000) });
819
+ onEvent?.({ type: 'tool_result', name: call.name, input: call.input, ok: true, output });
820
+ results.push(result(call.id, output, false));
821
+ // A changed file is progress: calls made after it are not repeats of those before.
822
+ if (['write_file', 'edit_file'].includes(call.name)) changes += 1;
823
+ // read_image: the pixels ride along as an image block in the tool-result
824
+ // message, so the model actually sees the picture instead of a caption of it.
825
+ // read_image: the pixels ride along in this message, after the results. When no
826
+ // image can be attached the call FAILED and says why — it used to report success
827
+ // either way, and the model then described a picture it never saw.
828
+ if (call.name === 'read_image') {
829
+ let block = null;
830
+ let why = '';
831
+ try { block = await readImageForModel(cwd, call.input?.path); } catch (e) { why = e.message; }
832
+ results[results.length - 1] = block
833
+ ? { ...result(call.id, 'The image is attached to this message, after the tool results.', false), __images: [block] }
834
+ : result(call.id, why || 'The image could not be attached.', true);
835
+ }
836
+ // todo_write: the checklist is for the human — checkbox cells with progress.
837
+ if (call.name === 'todo_write' && Array.isArray(call.input?.todos)) {
838
+ if (tui?.todo) tui.todo(call.input.todos);
839
+ else if (!silent) {
840
+ const icon = { pending: c.dim(glyph('☐', '[ ]')), in_progress: c.accent(glyph('◐', '[>]')), completed: c.accent(glyph('☑', '[x]')) };
841
+ for (const t of call.input.todos.slice(0, 12)) {
842
+ out(` ${(icon[t?.status] || icon.pending)} ${t?.status === 'completed' ? c.dim(String(t?.task || '').slice(0, 90)) : String(t?.task || '').slice(0, 90)}`);
843
+ }
844
+ }
845
+ }
846
+
847
+ // A write or an edit shows what it actually did. The journal snapshotted the
848
+ // file a few lines above, so the "before" side is already in memory — and a
849
+ // claim like "Edited src/index.js" is exactly the kind of thing that turns out
850
+ // to have changed the wrong line.
851
+ if (tui?.diff && journal && ['write_file', 'edit_file'].includes(call.name) && call.input?.path) {
852
+ try {
853
+ const absolute = path.resolve(cwd, String(call.input.path));
854
+ const before = journal.latestSnapshot(absolute);
855
+ if (before !== null) {
856
+ const { readFileSync } = await import('node:fs');
857
+ const patch = unifiedDiff(before, readFileSync(absolute, 'utf8'), { file: call.input.path });
858
+ if (patch) tui.diff(String(call.input.path), patch);
859
+ }
860
+ } catch { /* a missing diff never blocks the turn */ }
861
+ }
862
+ } catch (e) {
863
+ spin2.stop(); stopElapsed();
864
+ const msg = e instanceof ToolError ? e.message : `${e.message}`;
865
+ const took = ((Date.now() - toolStarted) / 1000).toFixed(1);
866
+ const bad = ` ${c.red(glyph('✗', 'x'))} ${c.dim(String(label).slice(0, 80))} ${c.dim(`· ${took}s`)} — ${msg.slice(0, 120)}`;
867
+ if (row) {
868
+ tui.clearStatus();
869
+ row.update({ status: 'failed', took, error: msg.split('\n')[0].slice(0, 200), preview: msg.includes('\n') ? msg : '', full: msg.slice(0, 200_000) });
870
+ } else if (tui) { tui.clearStatus(); tui.print(bad, 'tool'); } else if (!silent) out(bad);
871
+ tui?.recordActivity?.({ id: activityId, name: call.name, label, status: 'failed', detail: msg.slice(0, 2000) });
872
+ onEvent?.({ type: 'tool_result', name: call.name, input: call.input, ok: false, error: msg });
873
+ results.push(result(call.id, msg, true));
874
+ }
875
+ }
876
+
877
+ // Every tool_result first, then any images, in the same user message. Anthropic's
878
+ // format requires the results to lead the message; an image between two results
879
+ // (read_image followed by another call in one batch) was a rejected request.
880
+ const images = [];
881
+ for (const r of results) {
882
+ for (const img of r.__images || []) images.push(img);
883
+ delete r.__images;
884
+ }
885
+ messages.push({ role: 'user', content: [...results, ...images] });
886
+ if (signal?.aborted) {
887
+ if (!silent) out(c.yellow(` ${glyph('⏹', '[x]')} Interrupted.`));
888
+ return { ...totals, stopReason: 'interrupted' };
889
+ }
890
+ }
891
+
892
+ if (!silent) out(c.yellow(`\nStopped after ${maxIterations} steps — the safety limit for one request. Say "continue" to keep going.`));
893
+ tui?.bellIfAway?.();
894
+ return { ...totals, stopReason: 'max_iterations' };
895
+ }
896
+
897
+ /**
898
+ * A read-only subagent, as a tool the model can call.
899
+ *
900
+ * The point is context, not concurrency. "Find every place that reads the session file"
901
+ * costs forty tool results, and forty tool results spent in the main conversation are
902
+ * forty tool results the main conversation is still carrying three turns later. A
903
+ * subagent spends them in its own window and returns the paragraph that mattered.
904
+ *
905
+ * Read-only by construction: it is handed a permissions object in plan mode, so every
906
+ * write and every shell command is refused no matter what the parent's mode is. A
907
+ * subagent the user never sees must not be able to change the disk.
908
+ */
909
+ function makeSubagentTool({ client, model, cwd, signal, tui, permissions = null }) {
910
+ return {
911
+ name: 'task',
912
+ needsPermission: false,
913
+ description: 'Delegate a read-only investigation to a separate agent with its own context window. '
914
+ + 'Use it when answering needs a lot of searching and reading whose intermediate output you do not need to keep — '
915
+ + '"find every caller of X", "work out how Y is wired", "check whether Z is covered by tests". '
916
+ + 'The subagent can read, list, search and fetch, but cannot write files or run commands. '
917
+ + 'Give it a self-contained brief: it cannot see this conversation. It returns its findings as text.',
918
+ input_schema: {
919
+ type: 'object',
920
+ properties: {
921
+ description: { type: 'string', description: 'Three to five words naming the task, for the activity row.' },
922
+ prompt: { type: 'string', description: 'The full self-contained brief, including what to report back.' },
923
+ },
924
+ required: ['description', 'prompt'],
925
+ },
926
+ summarise: (input) => String(input.description || 'subagent'),
927
+ async run(input, { cwd: runCwd }) {
928
+ const { createPermissions } = await import('./permissions.js');
929
+ const messages = [{ role: 'user', content: String(input.prompt || '') }];
930
+ let answer = '';
931
+ const result = await runAgent({
932
+ client,
933
+ messages,
934
+ model,
935
+ cwd: runCwd || cwd,
936
+ permissions: permissions?.fork ? permissions.fork({ mode: 'plan', interactive: false }) : createPermissions({ mode: 'plan', interactive: false }),
937
+ signal,
938
+ tui: null,
939
+ silent: true,
940
+ subagents: false,
941
+ // Read-only research across a codebase is many small reads; 20 cut it off
942
+ // mid-search. The stuck detector is what catches a loop.
943
+ maxIterations: 100,
944
+ extraSystem: '\n\nYou are a subagent. You cannot ask questions and nobody will read your intermediate '
945
+ + 'steps — only your final message. End with the findings themselves, with exact file paths and line '
946
+ + 'numbers where they apply, not a description of how you looked.',
947
+ onText: (text) => { answer += text; },
948
+ });
949
+ const trimmed = answer.trim();
950
+ if (!trimmed) return `The subagent finished (${result.stopReason}) without reporting anything.`;
951
+ return `${trimmed}\n\n[subagent: ${result.tools} tool calls, ${result.input + result.output} tokens]`;
952
+ },
953
+ };
954
+ }
955
+
956
+ /**
957
+ * What of a tool's output is worth putting on screen.
958
+ *
959
+ * Not everything: a `read_file` the agent asked for is the agent's business, and
960
+ * echoing 400 lines of it buries the reasoning that follows. A command's output, a
961
+ * search's hits and a fetch's first lines are the ones a human reads over the agent's
962
+ * shoulder — so those get a preview and the rest get a row.
963
+ */
964
+ function previewFor(name, output) {
965
+ const text = String(output ?? '');
966
+ if (!text.trim()) return '';
967
+ switch (name) {
968
+ case 'run_command':
969
+ case 'background_read':
970
+ return text;
971
+ case 'search':
972
+ case 'list_files':
973
+ case 'web_search':
974
+ return text;
975
+ case 'web_fetch':
976
+ return text.split('\n').slice(0, 8).join('\n');
977
+ case 'read_file':
978
+ case 'read_image':
979
+ case 'write_file':
980
+ case 'edit_file':
981
+ case 'todo_write':
982
+ return '';
983
+ default:
984
+ return text.length > 400 ? '' : text;
985
+ }
986
+ }
987
+
988
+ function result(id, content, isError) {
989
+ return { type: 'tool_result', tool_use_id: id, content: String(content), is_error: isError };
990
+ }