jaitus 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/LICENSE +32 -0
  2. package/README.md +248 -0
  3. package/bin/jaitus.js +60 -0
  4. package/package.json +46 -0
  5. package/src/agent/loop.js +548 -0
  6. package/src/agent/permissions.js +218 -0
  7. package/src/agent/rules.js +190 -0
  8. package/src/agent/tools.js +923 -0
  9. package/src/agent/websearch.js +522 -0
  10. package/src/api.js +72 -0
  11. package/src/background.js +85 -0
  12. package/src/brand.js +232 -0
  13. package/src/commands/code-review.js +8 -0
  14. package/src/commands/commit.js +73 -0
  15. package/src/commands/completion.js +68 -0
  16. package/src/commands/doctor.js +185 -0
  17. package/src/commands/explain.js +33 -0
  18. package/src/commands/init.js +62 -0
  19. package/src/commands/login.js +201 -0
  20. package/src/commands/logout.js +25 -0
  21. package/src/commands/research.js +60 -0
  22. package/src/commands/review.js +80 -0
  23. package/src/commands/security-review.js +8 -0
  24. package/src/commands/simplify.js +8 -0
  25. package/src/commands/test.js +68 -0
  26. package/src/commands/update.js +97 -0
  27. package/src/config.js +95 -0
  28. package/src/diff.js +129 -0
  29. package/src/errors.js +101 -0
  30. package/src/hooks.js +55 -0
  31. package/src/index.js +2503 -0
  32. package/src/ledger.js +73 -0
  33. package/src/logo-data.js +33 -0
  34. package/src/mcp.js +160 -0
  35. package/src/open.js +50 -0
  36. package/src/project.js +0 -0
  37. package/src/rewind.js +130 -0
  38. package/src/session.js +207 -0
  39. package/src/sessions.js +179 -0
  40. package/src/sixel.js +131 -0
  41. package/src/skills.js +62 -0
  42. package/src/term-sim.js +103 -0
  43. package/src/terminal.js +90 -0
  44. package/src/tui/ansi.js +287 -0
  45. package/src/tui/entries.js +366 -0
  46. package/src/tui/highlight.js +211 -0
  47. package/src/tui/logo.js +93 -0
  48. package/src/tui/markdown.js +280 -0
  49. package/src/tui/mouse.js +125 -0
  50. package/src/tui/theme.js +201 -0
  51. package/src/tui/widgets.js +226 -0
  52. package/src/tui/wordmark.js +135 -0
  53. package/src/tui-ink.js +1927 -0
  54. package/src/tui.js +2555 -0
  55. package/src/ui.js +562 -0
@@ -0,0 +1,548 @@
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 hard iteration cap. A model that gets stuck in a read-file loop would otherwise
19
+ // spend real money until someone notices.
20
+
21
+ import { byName, toolSchemas, ToolError, readImageForModel } from './tools.js';
22
+ import { PermissionDenied } from './permissions.js';
23
+ import path from 'node:path';
24
+ import { loadContext, CONTEXT_FILE } from '../project.js';
25
+ import { c, out, write, spinner, glyph } from '../ui.js';
26
+ import { discoverMcpTools } from '../mcp.js';
27
+ import { unifiedDiff } from '../diff.js';
28
+ import { fromResponse, fromGatewayBody } from '../errors.js';
29
+
30
+ const MAX_ITERATIONS = 40;
31
+
32
+ /** opencode tool icon per tool, muted when done. ASCII twins included. */
33
+ const TOOL_MARKS = {
34
+ run_command: ['$', '$'], read_file: ['→', '>'], read_image: ['→', '>'],
35
+ list_files: ['→', '>'], write_file: ['←', '<'], edit_file: ['←', '<'],
36
+ search: ['✱', '*'], web_fetch: ['%', '%'], web_search: ['◈', 'o'],
37
+ background_run: ['◉', 'o'], todo_write: ['⚙', '*'],
38
+ };
39
+ const toolMark = (name) => {
40
+ const [uni, ascii] = TOOL_MARKS[name] || ['⚙', '*'];
41
+ return c.dim(glyph(uni, ascii));
42
+ };
43
+
44
+ export const SYSTEM_PROMPT = `You are Jaitus, a coding agent running in a developer's terminal, inside their project directory.
45
+
46
+ How to work:
47
+ - Investigate before you change anything. Read the files you are about to edit; do not guess at their contents.
48
+ - Prefer edit_file over write_file for existing files. Keep changes minimal and in the style of the surrounding code.
49
+ - Run the project's own tests or build after a change when one exists, and report the real result — including when it fails.
50
+ - Use run_command for anything the shell does better: git, tests, package managers, linters.
51
+ - Never invent a file path, a function name, or a test result. If you are unsure, look.
52
+ - For current news or other web facts, report only details grounded in web_search or
53
+ web_fetch output. Include the supplied source URLs; never fill a missing headline or
54
+ date from memory.
55
+
56
+ How to talk:
57
+ - Be concise. This is a terminal, not a document.
58
+ - Say what you did and what happened, not what you are about to do.
59
+ - If something is blocked or you are missing information, say so plainly and ask.
60
+ - No preamble, no restating the question back.`;
61
+
62
+ /**
63
+ * The project's own briefing, appended to the system prompt.
64
+ *
65
+ * Fenced and labelled rather than concatenated, so instructions written by whoever owns
66
+ * the repo are visibly the repo's and cannot be mistaken for part of Jaitus's own rules.
67
+ */
68
+ export function systemPrompt(cwd) {
69
+ const project = cwd ? loadContext(cwd) : null;
70
+ if (!project) return SYSTEM_PROMPT;
71
+ return `${SYSTEM_PROMPT}
72
+
73
+ The project you are working in ships a ${CONTEXT_FILE} with notes for you. Follow it where
74
+ it applies, and prefer what it says over your own assumptions about this codebase.
75
+
76
+ <${CONTEXT_FILE}>
77
+ ${project}
78
+ </${CONTEXT_FILE}>`;
79
+ }
80
+
81
+ /** One streamed request. Returns { content, stopReason, usage }. */
82
+ async function streamTurn({ client, messages, model, system, tools, onText, onToolStart, signal }) {
83
+ const res = await client.messages({
84
+ model,
85
+ max_tokens: 8192,
86
+ system: system || SYSTEM_PROMPT,
87
+ tools,
88
+ messages,
89
+ stream: true,
90
+ }, { signal });
91
+
92
+ if (!res.ok) throw await fromResponse(res);
93
+ const requestId = res.headers?.get?.('x-request-id') || '';
94
+
95
+ // Blocks are keyed by the index the stream gives them, because they interleave.
96
+ const blocks = new Map();
97
+ let stopReason = null;
98
+ const usage = { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 };
99
+ // Usage can arrive at the START of a stream (Anthropic-format providers) or only at
100
+ // the END, in message_delta (OpenAI-format providers, translated by the gateway —
101
+ // their usage exists only once generation finishes). Reading input from message_start
102
+ // alone recorded every OpenAI-format turn as 0 input tokens.
103
+ const takeUsage = (u) => {
104
+ if (!u) return;
105
+ for (const k of ['input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens']) {
106
+ if (u[k] != null && Number.isFinite(Number(u[k]))) usage[k] = Number(u[k]);
107
+ }
108
+ };
109
+ let buffer = '';
110
+ // A failure reported INSIDE the stream, after a 200. Recorded here and thrown after
111
+ // the parse below: handle() runs inside a try whose catch exists to skip keepalives,
112
+ // so throwing from it would be swallowed — which is how a mid-stream error used to
113
+ // end an answer halfway with no explanation at all.
114
+ let streamError = null;
115
+
116
+ const handle = (event) => {
117
+ switch (event.type) {
118
+ case 'error':
119
+ streamError = fromGatewayBody(200, event, { requestId });
120
+ break;
121
+ case 'message_start':
122
+ takeUsage(event.message?.usage);
123
+ break;
124
+ case 'content_block_start': {
125
+ const b = event.content_block || {};
126
+ blocks.set(event.index, b.type === 'tool_use'
127
+ ? { type: 'tool_use', id: b.id, name: b.name, json: '' }
128
+ : { type: 'text', text: '' });
129
+ if (b.type === 'tool_use') onToolStart?.(b.name);
130
+ break;
131
+ }
132
+ case 'content_block_delta': {
133
+ const b = blocks.get(event.index);
134
+ if (!b) break;
135
+ const d = event.delta || {};
136
+ if (d.type === 'text_delta' && typeof d.text === 'string') {
137
+ b.text += d.text;
138
+ onText?.(d.text);
139
+ } else if (d.type === 'input_json_delta' && typeof d.partial_json === 'string') {
140
+ b.json += d.partial_json;
141
+ }
142
+ break;
143
+ }
144
+ case 'message_delta':
145
+ if (event.delta?.stop_reason) stopReason = event.delta.stop_reason;
146
+ takeUsage(event.usage);
147
+ break;
148
+ default:
149
+ break;
150
+ }
151
+ };
152
+
153
+ // fetch() yields Uint8Array, NOT a Node Buffer. `uint8.toString('utf8')` ignores the
154
+ // argument and returns "101,118,101,…" — comma-joined byte values — so the whole
155
+ // stream parses to nothing and the agent appears to answer with silence.
156
+ //
157
+ // TextDecoder with stream:true is also the only correct choice for a different reason:
158
+ // a multi-byte character can be split across two chunks, and decoding each chunk
159
+ // independently would corrupt it. The decoder holds the partial bytes until complete.
160
+ const decoder = new TextDecoder();
161
+
162
+ for await (const chunk of res.body) {
163
+ buffer += decoder.decode(chunk, { stream: true });
164
+ const frames = buffer.split('\n\n');
165
+ buffer = frames.pop() || '';
166
+ for (const frame of frames) {
167
+ for (const line of frame.split('\n')) {
168
+ if (!line.startsWith('data:')) continue;
169
+ const raw = line.slice(5).trim();
170
+ if (!raw || raw === '[DONE]') continue;
171
+ try { handle(JSON.parse(raw)); } catch { /* keepalive or partial */ }
172
+ }
173
+ }
174
+ if (streamError) break;
175
+ }
176
+ if (streamError) throw streamError;
177
+
178
+ const content = [...blocks.entries()]
179
+ .sort((a, b) => a[0] - b[0])
180
+ .map(([, b]) => {
181
+ if (b.type !== 'tool_use') return { type: 'text', text: b.text };
182
+ let input = {};
183
+ // The fragments only form valid JSON once joined. An empty string is a
184
+ // no-argument call, which is legitimate.
185
+ try { input = b.json.trim() ? JSON.parse(b.json) : {}; } catch { input = { __parse_error: b.json }; }
186
+ return { type: 'tool_use', id: b.id, name: b.name, input };
187
+ })
188
+ .filter((b) => b.type !== 'text' || b.text.length);
189
+
190
+ return { content, stopReason, usage };
191
+ }
192
+
193
+ /**
194
+ * Run one user request to completion.
195
+ * `messages` is mutated so the caller keeps the conversation across turns.
196
+ */
197
+ export async function runAgent({
198
+ client, messages, model, cwd, permissions, signal, tui = null,
199
+ extraSystem = '', maxIterations = MAX_ITERATIONS, silent = false,
200
+ onText = null, onEvent = null, systemOverride = null, journal = null,
201
+ subagents = true,
202
+ }) {
203
+ // input = fresh input, cached = prompt-cache reads (billed far cheaper), and
204
+ // context = the real size of the last prompt sent, as the provider counted it.
205
+ const totals = { input: 0, cached: 0, output: 0, tools: 0, context: 0 };
206
+ // Read once per request, not per turn: an agent that rewrites JAITUS.md mid-task
207
+ // should not change the rules it is being judged by halfway through.
208
+ const system = `${systemOverride || systemPrompt(cwd)}${extraSystem}`;
209
+ const mcp = await discoverMcpTools(cwd).catch(() => []);
210
+ // Subagents are offered only to the top-level agent. A subagent that can spawn
211
+ // subagents is an unbounded fan-out on a metered gateway, and the depth that would
212
+ // actually help — one — is the depth that is safe.
213
+ const subagent = subagents ? makeSubagentTool({ client, model, cwd, signal, tui }) : null;
214
+ const availableTools = [
215
+ ...toolSchemas(),
216
+ ...(subagent ? [{ name: subagent.name, description: subagent.description, input_schema: subagent.input_schema }] : []),
217
+ ...mcp.map((tool) => ({ name: tool.name, description: tool.description, input_schema: tool.input_schema })),
218
+ ];
219
+ const toolMap = {
220
+ ...byName,
221
+ ...(subagent ? { [subagent.name]: subagent } : {}),
222
+ ...Object.fromEntries(mcp.map((tool) => [tool.name, tool])),
223
+ };
224
+
225
+ for (let iteration = 0; iteration < maxIterations; iteration++) {
226
+ let streamed = false;
227
+ const spin = tui ? { stop: () => {}, update: () => {} } : (silent ? { stop: () => {}, update: () => {} } : spinner(glyph('Working…', 'Working...')));
228
+ if (tui) tui.setStatus(`${c.brand(glyph('•', '*'))} ${c.brand(glyph('Working…', 'Working...'))}`);
229
+ // Codex-style assistant cell: the • leads the turn the way › leads the
230
+ // user's. In scrollback the marker lands before the first streamed chunk.
231
+ let turnVoiced = false;
232
+
233
+ let turn;
234
+ try {
235
+ turn = await streamTurn({
236
+ client,
237
+ messages,
238
+ model,
239
+ system,
240
+ tools: availableTools,
241
+ signal,
242
+ onText: (text) => {
243
+ onText?.(text);
244
+ if (!streamed) {
245
+ spin.stop(); streamed = true;
246
+ if (tui) tui.clearStatus();
247
+ else if (!silent && !onText) { out(''); write(`${c.dim(glyph('•', '*'))} `); }
248
+ }
249
+ if (tui) {
250
+ // The transcript entry draws its own speaker mark now, so the loop no
251
+ // longer prepends one — two marks on the same line was the tell that the
252
+ // renderer and the loop each thought they owned the lead glyph.
253
+ turnVoiced = true;
254
+ tui.printStream(text);
255
+ } else if (!silent && !onText) write(text);
256
+ },
257
+ onToolStart: () => spin.update(glyph('Working…', 'Working...')),
258
+ });
259
+ } catch (e) {
260
+ spin.stop();
261
+ // Esc during streaming: stop mid-sentence, keep what landed, don't treat it
262
+ // as an error. The partial text is already on screen.
263
+ if (signal?.aborted || e?.name === 'AbortError') {
264
+ if (tui) tui.flushStream();
265
+ if (streamed && !silent) out('');
266
+ if (tui?.notice) tui.notice('Interrupted.', 'warn');
267
+ else if (tui) tui.print(c.yellow(` ${glyph('⏹', '[x]')} Interrupted.`));
268
+ else if (!silent) out(c.yellow(` ${glyph('⏹', '[x]')} Interrupted.`));
269
+ return { ...totals, stopReason: 'interrupted' };
270
+ }
271
+ throw e;
272
+ }
273
+ spin.stop();
274
+ if (tui) tui.flushStream();
275
+ if (streamed) { if (tui) tui.printStream('\n'); else if (!silent && !onText) out(''); }
276
+
277
+ totals.input += turn.usage.input_tokens || 0;
278
+ totals.cached += (turn.usage.cache_read_input_tokens || 0) + (turn.usage.cache_creation_input_tokens || 0);
279
+ totals.output += turn.usage.output_tokens || 0;
280
+ const promptSize = (turn.usage.input_tokens || 0) + (turn.usage.cache_read_input_tokens || 0) + (turn.usage.cache_creation_input_tokens || 0);
281
+ if (promptSize) totals.context = promptSize + (turn.usage.output_tokens || 0);
282
+ // The work bar's live count. It was declared and drawn but never fed, so it never
283
+ // appeared; now it moves after every step of the turn.
284
+ tui?.setTurnTokens?.(totals.input + totals.cached + totals.output);
285
+
286
+ // Append the assistant turn verbatim — tool_use blocks and all.
287
+ messages.push({ role: 'assistant', content: turn.content });
288
+ onEvent?.({ type: 'assistant_turn', content: turn.content, usage: turn.usage, iteration });
289
+
290
+ const calls = turn.content.filter((b) => b.type === 'tool_use');
291
+
292
+ // Plan mode: read-only by decree. The turn's text still lands (the model can think
293
+ // out loud), but nothing on disk or in a shell is allowed to happen.
294
+ const blockedInPlan = permissions.mode === 'plan' && calls.some((call) => toolMap[call.name]?.needsPermission);
295
+ if (blockedInPlan) {
296
+ messages.pop();
297
+ const note = c.yellow(` ${glyph('⏸', '[II]')} plan mode — writes and commands are blocked; read-only inspection can continue. Shift+Tab to a doing mode to act.`);
298
+ if (tui?.notice) tui.notice('plan mode — writes and commands are blocked; read-only inspection can continue. Shift+Tab to a doing mode to act.', 'warn');
299
+ else if (tui) tui.print(note);
300
+ else if (!silent) out(note);
301
+ return { ...totals, stopReason: 'plan_blocked' };
302
+ }
303
+
304
+ // Esc pressed while the model was mid-turn: stop before any more tools run.
305
+ if (signal?.aborted) {
306
+ if (!silent) out(c.yellow(` ${glyph('⏹', '[x]')} Interrupted.`));
307
+ return { ...totals, stopReason: 'interrupted' };
308
+ }
309
+
310
+ if (!calls.length) { tui?.bellIfAway?.(); return { ...totals, stopReason: turn.stopReason }; }
311
+
312
+ // Every call gets a result, whatever happened.
313
+ const results = [];
314
+ for (const call of calls) {
315
+ const tool = toolMap[call.name];
316
+ totals.tools += 1;
317
+
318
+ if (!tool) {
319
+ results.push(result(call.id, `No such tool: ${call.name}`, true));
320
+ continue;
321
+ }
322
+ if (call.input?.__parse_error !== undefined) {
323
+ results.push(result(call.id, 'Your tool arguments were not valid JSON. Send them again.', true));
324
+ continue;
325
+ }
326
+
327
+ const label = tool.summarise ? tool.summarise(call.input) : call.name;
328
+ const activityId = tui?.recordActivity?.({ name: call.name, label, status: 'waiting' });
329
+ // One row per call, mutated in place through waiting → running → ok/failed. The
330
+ // previous surface printed a fresh line per state change, so a session with
331
+ // twenty tool calls scrolled sixty lines of near-duplicates past the answer.
332
+ const row = tui?.tool?.({ name: call.name, label, status: 'waiting', file: call.input?.path });
333
+
334
+ try {
335
+ await permissions.request(tool, call.input, { cwd, tui });
336
+ } catch (e) {
337
+ if (e instanceof PermissionDenied) {
338
+ const declined = ` ${c.red(glyph('✗', 'x'))} ${c.dim('declined')}`;
339
+ if (row) row.update({ status: 'denied', error: e.comment ? `declined — "${e.comment}"` : 'declined' });
340
+ else if (tui) tui.print(declined);
341
+ else if (!silent) out(declined);
342
+ tui?.recordActivity?.({ id: activityId, name: call.name, label, status: 'failed', detail: 'Permission declined by user.' });
343
+ // Told to the model, not just the user: it needs to know to stop trying and
344
+ // propose something else instead. A decision comment rides along verbatim.
345
+ const note = e.comment
346
+ ? `The user declined this action with this instruction, which overrides your plan: "${e.comment}". Do not retry the declined action; follow the instruction instead.`
347
+ : `The user declined this action. Do not retry it; ask what they would prefer.`;
348
+ results.push(result(call.id, note, true));
349
+ continue;
350
+ }
351
+ throw e;
352
+ }
353
+
354
+ tui?.recordActivity?.({ id: activityId, name: call.name, label, status: 'running' });
355
+ row?.update({ status: 'running' });
356
+ const spin2 = tui ? { stop: () => {} } : (silent ? { stop: () => {} } : spinner(`${call.name}: ${label}`.slice(0, 100)));
357
+ const toolStarted = Date.now();
358
+ // Live elapsed ticker on the status row while a tool runs (1s cadence,
359
+ // silent when animations are off — progress you can watch, not noise).
360
+ let elapsedTimer = null;
361
+ const stopElapsed = () => { if (elapsedTimer) { clearInterval(elapsedTimer); elapsedTimer = null; } };
362
+ if (tui && tui.animationsOn?.()) {
363
+ const toolStatus = () => `${c.brand(glyph('•', '*'))} ${call.name}: ${c.dim(String(label).slice(0, 60))} ${c.dim(`· ${((Date.now() - toolStarted) / 1000).toFixed(0)}s`)}`;
364
+ tui.setStatus(toolStatus());
365
+ elapsedTimer = setInterval(() => tui.setStatus(toolStatus()), 1000);
366
+ if (elapsedTimer.unref) elapsedTimer.unref();
367
+ } else if (tui) {
368
+ tui.setStatus(`${c.brand(glyph('•', '*'))} ${call.name}: ${c.dim(String(label).slice(0, 70))}`);
369
+ }
370
+ try {
371
+ // Rewind safety net: before the agent overwrites or creates a file, remember
372
+ // what was there. write_file/edit_file take their targets from the input;
373
+ // run_command's target is unknowable, so its calls snapshot the whole
374
+ // tracked set instead (cheap: only files already touched this session).
375
+ if (journal && ['write_file', 'edit_file'].includes(call.name) && call.input?.path) {
376
+ try { journal.snapshotFile(path.resolve(cwd, String(call.input.path))); } catch { /* path never existed */ }
377
+ } else if (journal && call.name === 'run_command') {
378
+ journal.snapshotTracked(cwd);
379
+ }
380
+ const output = await tool.run(call.input, { cwd });
381
+ spin2.stop(); stopElapsed();
382
+ const took = ((Date.now() - toolStarted) / 1000).toFixed(1);
383
+ // opencode tool icons: $ shell, ← edits, → reads, ✱ search,
384
+ // % fetch, ◈ websearch, ◉ subagents, ⚙ everything else. Done
385
+ // rows rest muted; failures stay red.
386
+ const done = ` ${toolMark(call.name)} ${c.dim(String(label).slice(0, 100))} ${c.dim(`· ${took}s`)}`;
387
+ if (row) {
388
+ tui.clearStatus();
389
+ row.update({ status: 'ok', took, preview: previewFor(call.name, output) });
390
+ } else if (tui) { tui.clearStatus(); tui.print(done, 'tool'); } else if (!silent) out(done);
391
+ tui?.recordActivity?.({ id: activityId, name: call.name, label, status: 'ok', detail: String(output).slice(0, 2000) });
392
+ onEvent?.({ type: 'tool_result', name: call.name, input: call.input, ok: true, output });
393
+ results.push(result(call.id, output, false));
394
+ // read_image: the pixels ride along as an image block in the tool-result
395
+ // message, so the model actually sees the picture instead of a caption of it.
396
+ if (call.name === 'read_image' && !silent) {
397
+ const block = await readImageForModel(cwd, call.input?.path).catch(() => null);
398
+ if (block) results[results.length - 1] = { ...result(call.id, output, false), __images: [block] };
399
+ }
400
+ // todo_write: the checklist is for the human — checkbox cells with progress.
401
+ if (call.name === 'todo_write' && Array.isArray(call.input?.todos)) {
402
+ if (tui?.todo) tui.todo(call.input.todos);
403
+ else if (!silent) {
404
+ const icon = { pending: c.dim(glyph('☐', '[ ]')), in_progress: c.accent(glyph('◐', '[>]')), completed: c.accent(glyph('☑', '[x]')) };
405
+ for (const t of call.input.todos.slice(0, 12)) {
406
+ out(` ${(icon[t?.status] || icon.pending)} ${t?.status === 'completed' ? c.dim(String(t?.task || '').slice(0, 90)) : String(t?.task || '').slice(0, 90)}`);
407
+ }
408
+ }
409
+ }
410
+
411
+ // A write or an edit shows what it actually did. The journal snapshotted the
412
+ // file a few lines above, so the "before" side is already in memory — and a
413
+ // claim like "Edited src/index.js" is exactly the kind of thing that turns out
414
+ // to have changed the wrong line.
415
+ if (tui?.diff && journal && ['write_file', 'edit_file'].includes(call.name) && call.input?.path) {
416
+ try {
417
+ const absolute = path.resolve(cwd, String(call.input.path));
418
+ const before = journal.latestSnapshot(absolute);
419
+ if (before !== null) {
420
+ const { readFileSync } = await import('node:fs');
421
+ const patch = unifiedDiff(before, readFileSync(absolute, 'utf8'), { file: call.input.path });
422
+ if (patch) tui.diff(String(call.input.path), patch);
423
+ }
424
+ } catch { /* a missing diff never blocks the turn */ }
425
+ }
426
+ } catch (e) {
427
+ spin2.stop(); stopElapsed();
428
+ const msg = e instanceof ToolError ? e.message : `${e.message}`;
429
+ const took = ((Date.now() - toolStarted) / 1000).toFixed(1);
430
+ const bad = ` ${c.red(glyph('✗', 'x'))} ${c.dim(String(label).slice(0, 80))} ${c.dim(`· ${took}s`)} — ${msg.slice(0, 120)}`;
431
+ if (row) {
432
+ tui.clearStatus();
433
+ row.update({ status: 'failed', took, error: msg.split('\n')[0].slice(0, 200), preview: msg.includes('\n') ? msg : '' });
434
+ } else if (tui) { tui.clearStatus(); tui.print(bad, 'tool'); } else if (!silent) out(bad);
435
+ tui?.recordActivity?.({ id: activityId, name: call.name, label, status: 'failed', detail: msg.slice(0, 2000) });
436
+ onEvent?.({ type: 'tool_result', name: call.name, input: call.input, ok: false, error: msg });
437
+ results.push(result(call.id, msg, true));
438
+ }
439
+ }
440
+
441
+ // Image blocks ride in the same user message as their tool_result, each following
442
+ // the result it belongs to — that is the shape the vision API expects.
443
+ const withImages = [];
444
+ for (const r of results) {
445
+ withImages.push(r);
446
+ for (const img of r.__images || []) withImages.push(img);
447
+ delete r.__images;
448
+ }
449
+ messages.push({ role: 'user', content: withImages });
450
+ }
451
+
452
+ if (!silent) out(c.yellow(`\nStopped after ${maxIterations} steps without finishing. Say "continue" to keep going.`));
453
+ tui?.bellIfAway?.();
454
+ return { ...totals, stopReason: 'max_iterations' };
455
+ }
456
+
457
+ /**
458
+ * A read-only subagent, as a tool the model can call.
459
+ *
460
+ * The point is context, not concurrency. "Find every place that reads the session file"
461
+ * costs forty tool results, and forty tool results spent in the main conversation are
462
+ * forty tool results the main conversation is still carrying three turns later. A
463
+ * subagent spends them in its own window and returns the paragraph that mattered.
464
+ *
465
+ * Read-only by construction: it is handed a permissions object in plan mode, so every
466
+ * write and every shell command is refused no matter what the parent's mode is. A
467
+ * subagent the user never sees must not be able to change the disk.
468
+ */
469
+ function makeSubagentTool({ client, model, cwd, signal, tui }) {
470
+ return {
471
+ name: 'task',
472
+ needsPermission: false,
473
+ description: 'Delegate a read-only investigation to a separate agent with its own context window. '
474
+ + 'Use it when answering needs a lot of searching and reading whose intermediate output you do not need to keep — '
475
+ + '"find every caller of X", "work out how Y is wired", "check whether Z is covered by tests". '
476
+ + 'The subagent can read, list, search and fetch, but cannot write files or run commands. '
477
+ + 'Give it a self-contained brief: it cannot see this conversation. It returns its findings as text.',
478
+ input_schema: {
479
+ type: 'object',
480
+ properties: {
481
+ description: { type: 'string', description: 'Three to five words naming the task, for the activity row.' },
482
+ prompt: { type: 'string', description: 'The full self-contained brief, including what to report back.' },
483
+ },
484
+ required: ['description', 'prompt'],
485
+ },
486
+ summarise: (input) => String(input.description || 'subagent'),
487
+ async run(input, { cwd: runCwd }) {
488
+ const { createPermissions } = await import('./permissions.js');
489
+ const messages = [{ role: 'user', content: String(input.prompt || '') }];
490
+ let answer = '';
491
+ const result = await runAgent({
492
+ client,
493
+ messages,
494
+ model,
495
+ cwd: runCwd || cwd,
496
+ permissions: createPermissions({ mode: 'plan' }),
497
+ signal,
498
+ tui: null,
499
+ silent: true,
500
+ subagents: false,
501
+ maxIterations: 20,
502
+ extraSystem: '\n\nYou are a subagent. You cannot ask questions and nobody will read your intermediate '
503
+ + 'steps — only your final message. End with the findings themselves, with exact file paths and line '
504
+ + 'numbers where they apply, not a description of how you looked.',
505
+ onText: (text) => { answer += text; },
506
+ });
507
+ const trimmed = answer.trim();
508
+ if (!trimmed) return `The subagent finished (${result.stopReason}) without reporting anything.`;
509
+ return `${trimmed}\n\n[subagent: ${result.tools} tool calls, ${result.input + result.output} tokens]`;
510
+ },
511
+ };
512
+ }
513
+
514
+ /**
515
+ * What of a tool's output is worth putting on screen.
516
+ *
517
+ * Not everything: a `read_file` the agent asked for is the agent's business, and
518
+ * echoing 400 lines of it buries the reasoning that follows. A command's output, a
519
+ * search's hits and a fetch's first lines are the ones a human reads over the agent's
520
+ * shoulder — so those get a preview and the rest get a row.
521
+ */
522
+ function previewFor(name, output) {
523
+ const text = String(output ?? '');
524
+ if (!text.trim()) return '';
525
+ switch (name) {
526
+ case 'run_command':
527
+ case 'background_read':
528
+ return text;
529
+ case 'search':
530
+ case 'list_files':
531
+ case 'web_search':
532
+ return text;
533
+ case 'web_fetch':
534
+ return text.split('\n').slice(0, 8).join('\n');
535
+ case 'read_file':
536
+ case 'read_image':
537
+ case 'write_file':
538
+ case 'edit_file':
539
+ case 'todo_write':
540
+ return '';
541
+ default:
542
+ return text.length > 400 ? '' : text;
543
+ }
544
+ }
545
+
546
+ function result(id, content, isError) {
547
+ return { type: 'tool_result', tool_use_id: id, content: String(content), is_error: isError };
548
+ }