jaitus 0.3.3 → 0.3.5

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/README.md CHANGED
@@ -94,49 +94,45 @@ changing a file or running a command, showing you exactly what it is about to do
94
94
  | `/hooks` | show lifecycle hooks |
95
95
  | `/tui [fullscreen\|default]` | switch renderer, relaunching with the conversation intact |
96
96
  | `/fork` | copy this conversation into a brand-new session |
97
- | `/theme [name]` | color theme: dark, light, daltonized, plain, auto |
98
- | `/scroll-speed [n]` | mouse-wheel speed, 0.25–10 |
97
+ | `/theme [name]` | color theme: dark, light, ocean, daltonized, auto (the default: asks the terminal for its background colour) |
98
+ | `/scroll-speed [n]` | mouse-wheel speed, 0.25–20 |
99
99
  | `/agent <task>` | run an isolated read-only side task |
100
100
  | `/cwd` | which directory the agent is working in |
101
101
  | `/clear` | start the conversation over |
102
102
  | `/undo` | drop the last exchange |
103
103
  | `/exit` | leave |
104
104
 
105
- Arrow keys, word-delete and history all work — history is kept in `~/.jaitus/history`
106
- and survives across sessions. Ctrl-C clears the line (or copies the selection,
107
- or interrupts a running turn) but never exits — only `/exit` leaves.
108
-
109
- Enter sends in one press — Tab completes a `/` command or `@` file without
110
- sending, Esc dismisses the suggestion first. `Ctrl+J`, `Shift+Enter` and
111
- `\+Enter` insert a newline. Up/Down (or `Ctrl+P`/`Ctrl+N`) moves inside a
112
- multiline prompt first, then through history. `Ctrl+R` searches history,
113
- `Shift+Tab` cycles permission modes, `Ctrl+T` shows session activity, `Ctrl+G`
114
- edits the draft in `$VISUAL`/`$EDITOR`, `Ctrl+S` stashes/recalls the draft,
115
- `Ctrl+_` undoes, `Ctrl+Y` pastes the last deletion, `Alt+B`/`F`/`D` do word
116
- jumps/edits, `?` on an empty prompt lists shortcuts. Large pastes collapse to
117
- `[Pasted text #N]` chips (full text still sends), `Ctrl+V` attaches a clipboard
118
- image, `:name:` inserts emoji, `! cmd &` or `Ctrl+B` backgrounds a command.
119
-
120
- Scrolling stays inside the app (alternate screen): `PgUp` /
121
- `PgDn` half a screen, `Ctrl+Home` / `Ctrl+End` top / bottom, mouse wheel a few
122
- lines. Scrolling up pauses auto-follow and shows new-message count — `Ctrl+End`
123
- or scroll to the bottom resumes. `JAITUS_SCROLL_SPEED` (1–20)
124
- multiplies wheel distance.
125
- `Ctrl+O` opens the transcript viewer: `/` searches, `n` / `N` next / previous,
126
- `j` / `k` lines, `Ctrl+u` / `Ctrl+d` half page, `Ctrl+b` / `Ctrl+f` / `Space`
127
- full page, `{` / `}` previous / next prompt, `g` / `G` ends, `[` dumps the full
128
- conversation to native scrollback for `Cmd+F` / tmux copy mode, `v` opens it in
129
- `$VISUAL` / `$EDITOR`, `q` / `Esc` returns.
130
-
131
- Drag selects inside the session and auto-copies on release (double-click word,
132
- triple-click line, `Shift+arrows` extends, `Ctrl+C` copies when a selection is
133
- active, `Ctrl+Shift+C` copies manually when auto-copy is off). Clipboard order:
134
- tmux buffer, `pbcopy` / `wl-copy` / `xclip` / `xsel` / `clip` / PowerShell
135
- `Set-Clipboard`, then OSC 52 over SSH. `jaitus config copyOnSelect on|off`
136
- toggles auto-copy. `JAITUS_DISABLE_MOUSE=1`
137
- keeps flicker-free rendering with native selection; `JAITUS_DISABLE_MOUSE_CLICKS=1`
138
- keeps wheel scrolling only. Hold `Shift` (`Option` in iTerm2, `Fn` in
139
- Terminal.app) for one-off native selection.
105
+ Arrow keys, Home/End, `Ctrl+A`/`Ctrl+E`, `Ctrl+W` (delete a word), `Ctrl+U`/`Ctrl+K`
106
+ (delete to the start/end) all work, and the cursor is drawn where you are typing.
107
+ Prompt history is kept in `~/.jaitus/history` and carries over between sessions —
108
+ Up/Down walk it. `Ctrl+C` copies a selection if there is one, otherwise interrupts a
109
+ running turn or clears the line; it never exits — only `/exit` leaves.
110
+
111
+ Enter sends. `Ctrl+J`, `Alt+Enter`, `Shift+Enter` (where the terminal reports it) and
112
+ `\` then Enter insert a new line. Tab completes a `/` command or `@` file; Esc closes
113
+ the suggestion list first (press Enter again to send what you typed), and twice on an
114
+ empty prompt opens Rewind. `Shift+Tab` cycles permission modes, `Ctrl+P` opens the
115
+ command palette, `Ctrl+L` clears the screen (the conversation stays — `/clear` starts
116
+ over), `Ctrl+D` toggles debug details. `Ctrl+R` searches your past prompts (Enter
117
+ puts the one you pick in the box, unsent); `Ctrl+O` searches the conversation and
118
+ scrolls to the line you pick.
119
+
120
+ **Pasting.** Right-click or `Ctrl+V` pastes the clipboard; a long paste becomes a
121
+ `[#1 pasted 120 lines]` chip (the full text is sent). **Images:** drag an image file
122
+ onto the terminal, paste a copied screenshot with `Alt+V`, type `/image path.png`, or
123
+ mention `@screenshot.png` — each becomes an `[Image #N]` chip listed under the
124
+ composer. Backspace over a chip removes it; only images whose chip is still in the
125
+ message are sent. Screenshots over 3.5 MB are shrunk automatically.
126
+
127
+ Scrolling stays inside the app: `PgUp`/`PgDn`, `Ctrl+Home`/`Ctrl+End`, the mouse wheel.
128
+ Scrolling up pauses auto-follow; `Ctrl+End` (or a click in the conversation) returns
129
+ to the live end. Panels (`/diff`, `/tasks`, `/help`) scroll with the arrows, PgUp/PgDn
130
+ and the wheel.
131
+
132
+ Drag selects and copies on release; double-click selects a word, triple-click a line.
133
+ `jaitus config copyOnSelect off` turns auto-copy off — then `Ctrl+C` copies.
134
+ `JAITUS_DISABLE_MOUSE=1` gives the terminal its own selection back. Hold `Shift`
135
+ (`Option` in iTerm2) for a one-off native selection.
140
136
 
141
137
  ## Options
142
138
 
@@ -219,7 +215,7 @@ or pipes. Force either way with `JAITUS_FULLSCREEN=1` / `JAITUS_NO_FULLSCREEN=1`
219
215
  the default; without a terminal to ask in, an action that needs permission is refused
220
216
  rather than assumed.
221
217
  - Commands run with no TTY, so anything interactive exits instead of hanging forever.
222
- - The agent stops after 40 steps rather than looping on your bill.
218
+ - The agent runs until the task is done. If it repeats the same action four times without changing anything it stops and says so, rather than looping on your bill.
223
219
  - `jaitus doctor` never prints your sign-in token — only whether you are signed in.
224
220
 
225
221
  ## Signing in
package/bin/jaitus.js CHANGED
@@ -1,5 +1,18 @@
1
1
  #!/usr/bin/env node
2
- import main from '../src/index.js';
2
+ // The app is imported only after an unfinished update has completed: importing it
3
+ // statically would load a half-installed copy and crash before any check could run.
4
+ // Node 20 is the floor — checked FIRST: loading the app on an older Node crashed in its
5
+ // dependencies before this friendly message could print.
6
+ if (Number(process.versions.node.split('.')[0]) < 20) {
7
+ process.stderr.write(`jaitus needs Node 20 or newer (you have ${process.versions.node}).
8
+ `);
9
+ process.exit(1);
10
+ }
11
+
12
+ import { waitForPendingUpdate } from '../src/pendingUpdate.js';
13
+
14
+ await waitForPendingUpdate().catch(() => false);
15
+ const { default: main } = await import('../src/index.js');
3
16
 
4
17
  // Node 20 is the floor: the CLI uses global fetch, and on 18 it exists but streaming
5
18
  // bodies behave differently enough to matter. Better a clear message than a confusing
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jaitus",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "description": "Jaitus — a coding agent in your terminal.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,7 +16,7 @@
16
16
  "node": ">=20"
17
17
  },
18
18
  "scripts": {
19
- "test": "node tests/source-sanity.js && node tests/rules.js && node tests/run.js && node tests/commands.js && node tests/login.js && node tests/session.js && node tests/openUrl.js && node tests/ink-tui.js && node tests/tui-render.js && node tests/mouse-e2e.js && node tests/environment.js && node tests/update.js && node tests/continuation.js && node tests/context-budget.js",
19
+ "test": "node tests/source-sanity.js && node tests/rules.js && node tests/run.js && node tests/commands.js && node tests/login.js && node tests/session.js && node tests/openUrl.js && node tests/ink-tui.js && node tests/tui-render.js && node tests/mouse-e2e.js && node tests/environment.js && node tests/update.js && node tests/continuation.js && node tests/steps.js && node tests/files.js && node tests/processes.js && node tests/security.js && node tests/context-budget.js",
20
20
  "preview": "node scripts/tui-preview.js",
21
21
  "prepublishOnly": "npm test"
22
22
  },
package/src/agent/loop.js CHANGED
@@ -15,8 +15,12 @@
15
15
  // • Every tool_use must get a tool_result, including ones that failed or were refused.
16
16
  // A missing result is a malformed conversation, and the error text is also how the
17
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.
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.
20
24
 
21
25
  import { byName, toolSchemas, ToolError, readImageForModel } from './tools.js';
22
26
  import { PermissionDenied } from './permissions.js';
@@ -29,7 +33,9 @@ import { discoverMcpTools } from '../mcp.js';
29
33
  import { unifiedDiff } from '../diff.js';
30
34
  import { fromResponse, fromGatewayBody } from '../errors.js';
31
35
 
32
- const MAX_ITERATIONS = 40;
36
+ const MAX_ITERATIONS = 1000;
37
+ const STUCK_REPEATS = 4;
38
+ const POLLING_TOOLS = new Set(['background_read', 'background_list']);
33
39
 
34
40
  // Room for a long answer or a large file in one reply. The models behind Jaitus reason
35
41
  // before they answer, and that reasoning is spent from the same budget — at 8192 a long
@@ -48,6 +54,33 @@ export const CONTINUE_NOTE = 'Your previous reply was cut off before it finished
48
54
  // many times before it is reported.
49
55
  const NETWORK_RETRIES = 2;
50
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
+
51
84
  /** opencode tool icon per tool, muted when done. ASCII twins included. */
52
85
  const TOOL_MARKS = {
53
86
  run_command: ['$', '$'], read_file: ['→', '>'], read_image: ['→', '>'],
@@ -158,6 +191,67 @@ ${project}
158
191
  </${CONTEXT_FILE}>`;
159
192
  }
160
193
 
194
+ /**
195
+ * Every earlier image is sent again with every request, and the gateway accepts at most
196
+ * 10 MB per request — three or four screenshots into a conversation, every turn failed.
197
+ * The newest images are kept up to IMAGE_BUDGET bytes of base64; older ones are replaced
198
+ * by a line saying one was there. The history itself is not changed.
199
+ */
200
+ /**
201
+ * Make a history well-formed after any interruption: every assistant tool_use has a
202
+ * tool_result in the next message. A run stopped by Esc, a failed permission prompt or
203
+ * a gateway error partway through could leave a tool call with no result — and both
204
+ * provider formats reject that, so every later request in the session failed. Missing
205
+ * results are filled in as "not run"; an assistant turn that ends the history with tool
206
+ * calls gets its results appended.
207
+ */
208
+ export function repairToolPairs(messages) {
209
+ for (let i = 0; i < messages.length; i++) {
210
+ const m = messages[i];
211
+ if (m?.role !== 'assistant' || !Array.isArray(m.content)) continue;
212
+ const ids = m.content.filter((b) => b?.type === 'tool_use').map((b) => b.id);
213
+ if (!ids.length) continue;
214
+ const next = messages[i + 1];
215
+ const have = new Set(next?.role === 'user' && Array.isArray(next.content)
216
+ ? next.content.filter((b) => b?.type === 'tool_result').map((b) => b.tool_use_id) : []);
217
+ const missing = ids.filter((id) => !have.has(id));
218
+ if (!missing.length) continue;
219
+ const filled = missing.map((id) => result(id, 'Not run: the task was interrupted before this step.', true));
220
+ if (next?.role === 'user') {
221
+ const content = Array.isArray(next.content) ? next.content : [{ type: 'text', text: String(next.content ?? '') }];
222
+ const results = content.filter((b) => b?.type === 'tool_result');
223
+ const rest = content.filter((b) => b?.type !== 'tool_result');
224
+ messages[i + 1] = { ...next, content: [...results, ...filled, ...rest] };
225
+ } else {
226
+ messages.splice(i + 1, 0, { role: 'user', content: filled });
227
+ }
228
+ }
229
+ return messages;
230
+ }
231
+
232
+ export const IMAGE_BUDGET = 6_000_000;
233
+ export function withinImageBudget(messages, budget = IMAGE_BUDGET) {
234
+ let used = 0;
235
+ let changed = false;
236
+ const out = messages.slice();
237
+ for (let i = out.length - 1; i >= 0; i--) {
238
+ const m = out[i];
239
+ if (!Array.isArray(m?.content) || !m.content.some((b) => b?.type === 'image')) continue;
240
+ // Walk this message's blocks newest-last, so within a message the later images win.
241
+ const kept = new Array(m.content.length);
242
+ for (let j = m.content.length - 1; j >= 0; j--) {
243
+ const b = m.content[j];
244
+ if (b?.type !== 'image') { kept[j] = b; continue; }
245
+ const size = String(b.source?.data || '').length;
246
+ if (used + size <= budget) { used += size; kept[j] = b; continue; }
247
+ kept[j] = { type: 'text', text: '[An image was attached here earlier; it is no longer included, to keep the request small.]' };
248
+ changed = true;
249
+ }
250
+ out[i] = { ...m, content: kept };
251
+ }
252
+ return changed ? out : messages;
253
+ }
254
+
161
255
  /** One streamed request. Returns { content, stopReason, usage }. */
162
256
  async function streamTurn({ client, messages, model, system, tools, onText, onToolStart, signal }) {
163
257
  const res = await client.messages({
@@ -165,7 +259,7 @@ async function streamTurn({ client, messages, model, system, tools, onText, onTo
165
259
  max_tokens: MAX_OUTPUT_TOKENS,
166
260
  system: system || SYSTEM_PROMPT,
167
261
  tools,
168
- messages,
262
+ messages: withinImageBudget(messages),
169
263
  stream: true,
170
264
  }, { signal });
171
265
 
@@ -269,7 +363,13 @@ async function streamTurn({ client, messages, model, system, tools, onText, onTo
269
363
  if (!blocks.size) throw e;
270
364
  cut = true;
271
365
  }
272
- if (streamError) throw streamError;
366
+ if (streamError) {
367
+ // The provider dropped out after part of the answer had arrived. What arrived is
368
+ // kept and continued from, exactly as when OUR connection drops — it used to be
369
+ // thrown away with a "Something went wrong" and the task stopped.
370
+ if (blocks.size && TRANSIENT_CODES.has(streamError.code)) cut = true;
371
+ else throw streamError;
372
+ }
273
373
  if (cut || (!sawStop && !stopReason)) stopReason = 'cut';
274
374
 
275
375
  const content = [...blocks.entries()]
@@ -295,19 +395,29 @@ export async function runAgent({
295
395
  client, messages, model, cwd, permissions, signal, tui = null,
296
396
  extraSystem = '', maxIterations = MAX_ITERATIONS, silent = false,
297
397
  onText = null, onEvent = null, systemOverride = null, journal = null,
298
- subagents = true,
398
+ subagents = true, onContext = null,
299
399
  }) {
400
+ // Loop detection. A call is identified by its tool, its input, and how many files had
401
+ // been changed when it was made — so edit → test → edit → test is progress (the count
402
+ // moves), while reading the same file over and over is not.
403
+ let changes = 0;
404
+ const repeats = new Map();
300
405
  // input = fresh input, cached = prompt-cache reads (billed far cheaper), and
301
406
  // context = the real size of the last prompt sent, as the provider counted it.
302
407
  const totals = { input: 0, cached: 0, output: 0, tools: 0, context: 0 };
303
408
  // Read once per request, not per turn: an agent that rewrites JAITUS.md mid-task
304
409
  // should not change the rules it is being judged by halfway through.
305
- const system = `${systemOverride || systemPrompt(cwd)}${extraSystem}`;
410
+ // The model is told when it is in plan mode — it used to find out only by having its
411
+ // writes refused, after it had already tried them.
412
+ const planNote = permissions?.mode === 'plan'
413
+ ? '\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.'
414
+ : '';
415
+ const system = `${systemOverride || systemPrompt(cwd)}${extraSystem}${planNote}`;
306
416
  const mcp = await discoverMcpTools(cwd).catch(() => []);
307
417
  // Subagents are offered only to the top-level agent. A subagent that can spawn
308
418
  // subagents is an unbounded fan-out on a metered gateway, and the depth that would
309
419
  // actually help — one — is the depth that is safe.
310
- const subagent = subagents ? makeSubagentTool({ client, model, cwd, signal, tui }) : null;
420
+ const subagent = subagents ? makeSubagentTool({ client, model, cwd, signal, tui, permissions }) : null;
311
421
  const availableTools = [
312
422
  ...toolSchemas(),
313
423
  ...(subagent ? [{ name: subagent.name, description: subagent.description, input_schema: subagent.input_schema }] : []),
@@ -321,6 +431,8 @@ export async function runAgent({
321
431
 
322
432
  let continuations = 0;
323
433
  let networkRetries = 0;
434
+ let busyRetries = 0;
435
+ let planNoticeShown = false;
324
436
  // True while the previous reply is being continued: its text is still on screen as
325
437
  // one live message, and the continuation must extend it rather than start another.
326
438
  let continuing = false;
@@ -372,6 +484,21 @@ export async function runAgent({
372
484
  iteration -= 1;
373
485
  continue;
374
486
  }
487
+ // Busy, rate-limited for a moment, unavailable, timed out: the gateway says to
488
+ // back off and retry (it sends Retry-After), and nothing used to — one provider 429
489
+ // ended a task halfway. A limit (plan, free tier, a daily cap hours away) or a
490
+ // sign-in problem is never retried.
491
+ const wait = retryDelay(e, busyRetries);
492
+ if (!(signal?.aborted || e?.name === 'AbortError') && wait != null && busyRetries < BUSY_RETRIES) {
493
+ busyRetries += 1;
494
+ const secs = Math.max(1, Math.round(wait / 1000));
495
+ if (tui?.setStatus) tui.setStatus(`${c.yellow('!')} ${c.dim(`Jaitus is busy — trying again in ${secs}s (${busyRetries} of ${BUSY_RETRIES})`)}`);
496
+ const ok = await sleepUnlessAborted(wait, signal);
497
+ tui?.clearStatus?.();
498
+ if (!ok) return { ...totals, stopReason: 'interrupted' };
499
+ iteration -= 1;
500
+ continue;
501
+ }
375
502
  if (signal?.aborted || e?.name === 'AbortError') {
376
503
  if (tui) tui.flushStream();
377
504
  if (streamed && !silent) out('');
@@ -384,6 +511,14 @@ export async function runAgent({
384
511
  }
385
512
  spin.stop();
386
513
  networkRetries = 0;
514
+ busyRetries = 0;
515
+ // A reply the provider's content filter stopped is cut short; say so rather than
516
+ // letting half an answer pass for the whole one.
517
+ if (turn.stopReason === 'refusal') {
518
+ const why = 'The answer was stopped partway by a content filter. Rephrasing the request usually gets past it.';
519
+ if (tui?.notice) tui.notice(why, 'warn');
520
+ else if (!silent) out(c.yellow(` ${why}`));
521
+ }
387
522
  const incomplete = turn.stopReason === 'max_tokens' || turn.stopReason === 'cut';
388
523
  // A tool call cut off halfway has JSON that is not whole; it is dropped and the model
389
524
  // is asked to make it again.
@@ -398,7 +533,13 @@ export async function runAgent({
398
533
  totals.cached += (turn.usage.cache_read_input_tokens || 0) + (turn.usage.cache_creation_input_tokens || 0);
399
534
  totals.output += turn.usage.output_tokens || 0;
400
535
  const promptSize = (turn.usage.input_tokens || 0) + (turn.usage.cache_read_input_tokens || 0) + (turn.usage.cache_creation_input_tokens || 0);
401
- if (promptSize) totals.context = promptSize + (turn.usage.output_tokens || 0);
536
+ if (promptSize) {
537
+ totals.context = promptSize + (turn.usage.output_tokens || 0);
538
+ // The header's context meter moves after every step. It used to be told only when
539
+ // the whole run returned, so through a long task — and after one stopped with
540
+ // Esc or an error — it sat at 0.
541
+ try { onContext?.(totals.context); } catch { /* the meter never breaks a turn */ }
542
+ }
402
543
  // The work bar's live count. It was declared and drawn but never fed, so it never
403
544
  // appeared; now it moves after every step of the turn.
404
545
  tui?.setTurnTokens?.(totals.input + totals.cached + totals.output);
@@ -429,14 +570,18 @@ export async function runAgent({
429
570
 
430
571
  // Plan mode: read-only by decree. The turn's text still lands (the model can think
431
572
  // out loud), but nothing on disk or in a shell is allowed to happen.
432
- const blockedInPlan = permissions.mode === 'plan' && calls.some((call) => toolMap[call.name]?.needsPermission);
433
- if (blockedInPlan) {
434
- messages.pop();
435
- 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.`);
436
- 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');
437
- else if (tui) tui.print(note);
438
- else if (!silent) out(note);
439
- return { ...totals, stopReason: 'plan_blocked' };
573
+ // web_fetch is not a write: in plan mode it goes to its own prompt instead of ending the turn.
574
+ // Plan mode: a write or command gets a "not run — plan instead" result, the reads in
575
+ // the same batch still run, and the turn — with the plan the model just wrote — stays
576
+ // in the history. It used to pop the whole turn, so "ok, do it" found no plan to do.
577
+ const planBlocked = permissions.mode === 'plan'
578
+ ? new Set(calls.filter((call) => toolMap[call.name]?.needsPermission && !toolMap[call.name]?.network).map((call) => call.id))
579
+ : new Set();
580
+ if (planBlocked.size && !planNoticeShown) {
581
+ planNoticeShown = true;
582
+ const text = 'Plan mode — writes and commands are not run; the agent can still read. Shift+Tab (or /code) to let it act.';
583
+ if (tui?.notice) tui.notice(text, 'warn');
584
+ else if (!silent) out(c.yellow(` ${glyph('⏸', '[II]')} ${text}`));
440
585
  }
441
586
 
442
587
  // Esc pressed while the model was mid-turn: stop before any more tools run.
@@ -447,12 +592,46 @@ export async function runAgent({
447
592
 
448
593
  if (!calls.length) { tui?.bellIfAway?.(); return { ...totals, stopReason: turn.stopReason }; }
449
594
 
595
+ // The same call, same input, nothing changed since: STUCK_REPEATS times is a loop.
596
+ // Checked before running, so the repeat that proves it is not paid for again.
597
+ const stuckOn = calls.find((call) => {
598
+ // Polling is supposed to repeat: background_read's own description says "poll
599
+ // this", and four polls of a starting dev server are not a loop.
600
+ if (POLLING_TOOLS.has(call.name)) return false;
601
+ const key = `${changes}\u0000${call.name}\u0000${JSON.stringify(call.input ?? null)}`;
602
+ const n = (repeats.get(key) || 0) + 1;
603
+ repeats.set(key, n);
604
+ return n >= STUCK_REPEATS;
605
+ });
606
+ if (stuckOn) {
607
+ // Every tool_use still gets a result — the model is told why these did not run, so
608
+ // after "continue" it does not simply make the same call a fifth time.
609
+ messages.push({ role: 'user', content: calls.map((call) => result(call.id,
610
+ `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)) });
611
+ const label = toolMap[stuckOn.name]?.summarise ? toolMap[stuckOn.name].summarise(stuckOn.input) : stuckOn.name;
612
+ 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".`;
613
+ if (tui?.notice) tui.notice(why, 'warn');
614
+ else if (!silent) out(c.yellow(`\n${why}`));
615
+ tui?.bellIfAway?.();
616
+ return { ...totals, stopReason: 'stuck' };
617
+ }
618
+
450
619
  // Every call gets a result, whatever happened.
451
620
  const results = [];
452
621
  for (const call of calls) {
453
622
  const tool = toolMap[call.name];
454
623
  totals.tools += 1;
455
624
 
625
+ // Esc during a batch stops the REST of it. The check used to run once before the
626
+ // batch, so after Esc the remaining calls still prompted and ran.
627
+ if (signal?.aborted) {
628
+ results.push(result(call.id, 'Not run: the user stopped the task.', true));
629
+ continue;
630
+ }
631
+ if (planBlocked.has(call.id)) {
632
+ 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));
633
+ continue;
634
+ }
456
635
  if (!tool) {
457
636
  results.push(result(call.id, `No such tool: ${call.name}`, true));
458
637
  continue;
@@ -470,7 +649,7 @@ export async function runAgent({
470
649
  const row = tui?.tool?.({ name: call.name, label, status: 'waiting', file: call.input?.path });
471
650
 
472
651
  try {
473
- await permissions.request(tool, call.input, { cwd, tui });
652
+ await permissions.request(tool, call.input, { cwd, tui, signal });
474
653
  } catch (e) {
475
654
  if (e instanceof PermissionDenied) {
476
655
  const declined = ` ${c.red(glyph('✗', 'x'))} ${c.dim('declined')}`;
@@ -480,9 +659,14 @@ export async function runAgent({
480
659
  tui?.recordActivity?.({ id: activityId, name: call.name, label, status: 'failed', detail: 'Permission declined by user.' });
481
660
  // Told to the model, not just the user: it needs to know to stop trying and
482
661
  // propose something else instead. A decision comment rides along verbatim.
483
- const note = e.comment
484
- ? `The user declined this action with this instruction, which overrides your plan: "${e.comment}". Do not retry the declined action; follow the instruction instead.`
485
- : `The user declined this action. Do not retry it; ask what they would prefer.`;
662
+ // A run that could not ask (one-shot, a script) says so — "the user declined"
663
+ // was untrue there, and hid the hint about --yes from everyone.
664
+ if (e.headless) totals.denied = (totals.denied || 0) + 1;
665
+ const note = e.headless
666
+ ? `Not permitted in this run: ${e.message} Do not retry it; finish what you can without it and say what was left undone.`
667
+ : e.comment
668
+ ? `The user declined this action with this instruction, which overrides your plan: "${e.comment}". Do not retry the declined action; follow the instruction instead.`
669
+ : `The user declined this action. Do not retry it; ask what they would prefer.`;
486
670
  results.push(result(call.id, note, true));
487
671
  continue;
488
672
  }
@@ -515,7 +699,7 @@ export async function runAgent({
515
699
  } else if (journal && call.name === 'run_command') {
516
700
  journal.snapshotTracked(cwd);
517
701
  }
518
- const output = await tool.run(call.input, { cwd });
702
+ const output = await tool.run(call.input, { cwd, signal });
519
703
  spin2.stop(); stopElapsed();
520
704
  const took = ((Date.now() - toolStarted) / 1000).toFixed(1);
521
705
  // opencode tool icons: $ shell, ← edits, → reads, ✱ search,
@@ -529,11 +713,20 @@ export async function runAgent({
529
713
  tui?.recordActivity?.({ id: activityId, name: call.name, label, status: 'ok', detail: String(output).slice(0, 2000) });
530
714
  onEvent?.({ type: 'tool_result', name: call.name, input: call.input, ok: true, output });
531
715
  results.push(result(call.id, output, false));
716
+ // A changed file is progress: calls made after it are not repeats of those before.
717
+ if (['write_file', 'edit_file'].includes(call.name)) changes += 1;
532
718
  // read_image: the pixels ride along as an image block in the tool-result
533
719
  // message, so the model actually sees the picture instead of a caption of it.
534
- if (call.name === 'read_image' && !silent) {
535
- const block = await readImageForModel(cwd, call.input?.path).catch(() => null);
536
- if (block) results[results.length - 1] = { ...result(call.id, output, false), __images: [block] };
720
+ // read_image: the pixels ride along in this message, after the results. When no
721
+ // image can be attached the call FAILED and says why — it used to report success
722
+ // either way, and the model then described a picture it never saw.
723
+ if (call.name === 'read_image') {
724
+ let block = null;
725
+ let why = '';
726
+ try { block = await readImageForModel(cwd, call.input?.path); } catch (e) { why = e.message; }
727
+ results[results.length - 1] = block
728
+ ? { ...result(call.id, 'The image is attached to this message, after the tool results.', false), __images: [block] }
729
+ : result(call.id, why || 'The image could not be attached.', true);
537
730
  }
538
731
  // todo_write: the checklist is for the human — checkbox cells with progress.
539
732
  if (call.name === 'todo_write' && Array.isArray(call.input?.todos)) {
@@ -576,18 +769,22 @@ export async function runAgent({
576
769
  }
577
770
  }
578
771
 
579
- // Image blocks ride in the same user message as their tool_result, each following
580
- // the result it belongs to — that is the shape the vision API expects.
581
- const withImages = [];
772
+ // Every tool_result first, then any images, in the same user message. Anthropic's
773
+ // format requires the results to lead the message; an image between two results
774
+ // (read_image followed by another call in one batch) was a rejected request.
775
+ const images = [];
582
776
  for (const r of results) {
583
- withImages.push(r);
584
- for (const img of r.__images || []) withImages.push(img);
777
+ for (const img of r.__images || []) images.push(img);
585
778
  delete r.__images;
586
779
  }
587
- messages.push({ role: 'user', content: withImages });
780
+ messages.push({ role: 'user', content: [...results, ...images] });
781
+ if (signal?.aborted) {
782
+ if (!silent) out(c.yellow(` ${glyph('⏹', '[x]')} Interrupted.`));
783
+ return { ...totals, stopReason: 'interrupted' };
784
+ }
588
785
  }
589
786
 
590
- if (!silent) out(c.yellow(`\nStopped after ${maxIterations} steps without finishing. Say "continue" to keep going.`));
787
+ if (!silent) out(c.yellow(`\nStopped after ${maxIterations} steps — the safety limit for one request. Say "continue" to keep going.`));
591
788
  tui?.bellIfAway?.();
592
789
  return { ...totals, stopReason: 'max_iterations' };
593
790
  }
@@ -604,7 +801,7 @@ export async function runAgent({
604
801
  * write and every shell command is refused no matter what the parent's mode is. A
605
802
  * subagent the user never sees must not be able to change the disk.
606
803
  */
607
- function makeSubagentTool({ client, model, cwd, signal, tui }) {
804
+ function makeSubagentTool({ client, model, cwd, signal, tui, permissions = null }) {
608
805
  return {
609
806
  name: 'task',
610
807
  needsPermission: false,
@@ -631,12 +828,14 @@ function makeSubagentTool({ client, model, cwd, signal, tui }) {
631
828
  messages,
632
829
  model,
633
830
  cwd: runCwd || cwd,
634
- permissions: createPermissions({ mode: 'plan' }),
831
+ permissions: permissions?.fork ? permissions.fork({ mode: 'plan', interactive: false }) : createPermissions({ mode: 'plan', interactive: false }),
635
832
  signal,
636
833
  tui: null,
637
834
  silent: true,
638
835
  subagents: false,
639
- maxIterations: 20,
836
+ // Read-only research across a codebase is many small reads; 20 cut it off
837
+ // mid-search. The stuck detector is what catches a loop.
838
+ maxIterations: 100,
640
839
  extraSystem: '\n\nYou are a subagent. You cannot ask questions and nobody will read your intermediate '
641
840
  + 'steps — only your final message. End with the findings themselves, with exact file paths and line '
642
841
  + 'numbers where they apply, not a description of how you looked.',