jaitus 0.3.4 → 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
 
package/bin/jaitus.js CHANGED
@@ -1,6 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  // The app is imported only after an unfinished update has completed: importing it
3
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
+
4
12
  import { waitForPendingUpdate } from '../src/pendingUpdate.js';
5
13
 
6
14
  await waitForPendingUpdate().catch(() => false);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jaitus",
3
- "version": "0.3.4",
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/steps.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
@@ -35,6 +35,7 @@ import { fromResponse, fromGatewayBody } from '../errors.js';
35
35
 
36
36
  const MAX_ITERATIONS = 1000;
37
37
  const STUCK_REPEATS = 4;
38
+ const POLLING_TOOLS = new Set(['background_read', 'background_list']);
38
39
 
39
40
  // Room for a long answer or a large file in one reply. The models behind Jaitus reason
40
41
  // before they answer, and that reasoning is spent from the same budget — at 8192 a long
@@ -53,6 +54,33 @@ export const CONTINUE_NOTE = 'Your previous reply was cut off before it finished
53
54
  // many times before it is reported.
54
55
  const NETWORK_RETRIES = 2;
55
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
+
56
84
  /** opencode tool icon per tool, muted when done. ASCII twins included. */
57
85
  const TOOL_MARKS = {
58
86
  run_command: ['$', '$'], read_file: ['→', '>'], read_image: ['→', '>'],
@@ -163,6 +191,67 @@ ${project}
163
191
  </${CONTEXT_FILE}>`;
164
192
  }
165
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
+
166
255
  /** One streamed request. Returns { content, stopReason, usage }. */
167
256
  async function streamTurn({ client, messages, model, system, tools, onText, onToolStart, signal }) {
168
257
  const res = await client.messages({
@@ -170,7 +259,7 @@ async function streamTurn({ client, messages, model, system, tools, onText, onTo
170
259
  max_tokens: MAX_OUTPUT_TOKENS,
171
260
  system: system || SYSTEM_PROMPT,
172
261
  tools,
173
- messages,
262
+ messages: withinImageBudget(messages),
174
263
  stream: true,
175
264
  }, { signal });
176
265
 
@@ -274,7 +363,13 @@ async function streamTurn({ client, messages, model, system, tools, onText, onTo
274
363
  if (!blocks.size) throw e;
275
364
  cut = true;
276
365
  }
277
- 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
+ }
278
373
  if (cut || (!sawStop && !stopReason)) stopReason = 'cut';
279
374
 
280
375
  const content = [...blocks.entries()]
@@ -312,12 +407,17 @@ export async function runAgent({
312
407
  const totals = { input: 0, cached: 0, output: 0, tools: 0, context: 0 };
313
408
  // Read once per request, not per turn: an agent that rewrites JAITUS.md mid-task
314
409
  // should not change the rules it is being judged by halfway through.
315
- 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}`;
316
416
  const mcp = await discoverMcpTools(cwd).catch(() => []);
317
417
  // Subagents are offered only to the top-level agent. A subagent that can spawn
318
418
  // subagents is an unbounded fan-out on a metered gateway, and the depth that would
319
419
  // actually help — one — is the depth that is safe.
320
- const subagent = subagents ? makeSubagentTool({ client, model, cwd, signal, tui }) : null;
420
+ const subagent = subagents ? makeSubagentTool({ client, model, cwd, signal, tui, permissions }) : null;
321
421
  const availableTools = [
322
422
  ...toolSchemas(),
323
423
  ...(subagent ? [{ name: subagent.name, description: subagent.description, input_schema: subagent.input_schema }] : []),
@@ -331,6 +431,8 @@ export async function runAgent({
331
431
 
332
432
  let continuations = 0;
333
433
  let networkRetries = 0;
434
+ let busyRetries = 0;
435
+ let planNoticeShown = false;
334
436
  // True while the previous reply is being continued: its text is still on screen as
335
437
  // one live message, and the continuation must extend it rather than start another.
336
438
  let continuing = false;
@@ -382,6 +484,21 @@ export async function runAgent({
382
484
  iteration -= 1;
383
485
  continue;
384
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
+ }
385
502
  if (signal?.aborted || e?.name === 'AbortError') {
386
503
  if (tui) tui.flushStream();
387
504
  if (streamed && !silent) out('');
@@ -394,6 +511,14 @@ export async function runAgent({
394
511
  }
395
512
  spin.stop();
396
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
+ }
397
522
  const incomplete = turn.stopReason === 'max_tokens' || turn.stopReason === 'cut';
398
523
  // A tool call cut off halfway has JSON that is not whole; it is dropped and the model
399
524
  // is asked to make it again.
@@ -445,14 +570,18 @@ export async function runAgent({
445
570
 
446
571
  // Plan mode: read-only by decree. The turn's text still lands (the model can think
447
572
  // out loud), but nothing on disk or in a shell is allowed to happen.
448
- const blockedInPlan = permissions.mode === 'plan' && calls.some((call) => toolMap[call.name]?.needsPermission);
449
- if (blockedInPlan) {
450
- messages.pop();
451
- 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.`);
452
- 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');
453
- else if (tui) tui.print(note);
454
- else if (!silent) out(note);
455
- 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}`));
456
585
  }
457
586
 
458
587
  // Esc pressed while the model was mid-turn: stop before any more tools run.
@@ -466,6 +595,9 @@ export async function runAgent({
466
595
  // The same call, same input, nothing changed since: STUCK_REPEATS times is a loop.
467
596
  // Checked before running, so the repeat that proves it is not paid for again.
468
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;
469
601
  const key = `${changes}\u0000${call.name}\u0000${JSON.stringify(call.input ?? null)}`;
470
602
  const n = (repeats.get(key) || 0) + 1;
471
603
  repeats.set(key, n);
@@ -490,6 +622,16 @@ export async function runAgent({
490
622
  const tool = toolMap[call.name];
491
623
  totals.tools += 1;
492
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
+ }
493
635
  if (!tool) {
494
636
  results.push(result(call.id, `No such tool: ${call.name}`, true));
495
637
  continue;
@@ -507,7 +649,7 @@ export async function runAgent({
507
649
  const row = tui?.tool?.({ name: call.name, label, status: 'waiting', file: call.input?.path });
508
650
 
509
651
  try {
510
- await permissions.request(tool, call.input, { cwd, tui });
652
+ await permissions.request(tool, call.input, { cwd, tui, signal });
511
653
  } catch (e) {
512
654
  if (e instanceof PermissionDenied) {
513
655
  const declined = ` ${c.red(glyph('✗', 'x'))} ${c.dim('declined')}`;
@@ -517,9 +659,14 @@ export async function runAgent({
517
659
  tui?.recordActivity?.({ id: activityId, name: call.name, label, status: 'failed', detail: 'Permission declined by user.' });
518
660
  // Told to the model, not just the user: it needs to know to stop trying and
519
661
  // propose something else instead. A decision comment rides along verbatim.
520
- const note = e.comment
521
- ? `The user declined this action with this instruction, which overrides your plan: "${e.comment}". Do not retry the declined action; follow the instruction instead.`
522
- : `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.`;
523
670
  results.push(result(call.id, note, true));
524
671
  continue;
525
672
  }
@@ -552,7 +699,7 @@ export async function runAgent({
552
699
  } else if (journal && call.name === 'run_command') {
553
700
  journal.snapshotTracked(cwd);
554
701
  }
555
- const output = await tool.run(call.input, { cwd });
702
+ const output = await tool.run(call.input, { cwd, signal });
556
703
  spin2.stop(); stopElapsed();
557
704
  const took = ((Date.now() - toolStarted) / 1000).toFixed(1);
558
705
  // opencode tool icons: $ shell, ← edits, → reads, ✱ search,
@@ -570,9 +717,16 @@ export async function runAgent({
570
717
  if (['write_file', 'edit_file'].includes(call.name)) changes += 1;
571
718
  // read_image: the pixels ride along as an image block in the tool-result
572
719
  // message, so the model actually sees the picture instead of a caption of it.
573
- if (call.name === 'read_image' && !silent) {
574
- const block = await readImageForModel(cwd, call.input?.path).catch(() => null);
575
- 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);
576
730
  }
577
731
  // todo_write: the checklist is for the human — checkbox cells with progress.
578
732
  if (call.name === 'todo_write' && Array.isArray(call.input?.todos)) {
@@ -615,15 +769,19 @@ export async function runAgent({
615
769
  }
616
770
  }
617
771
 
618
- // Image blocks ride in the same user message as their tool_result, each following
619
- // the result it belongs to — that is the shape the vision API expects.
620
- 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 = [];
621
776
  for (const r of results) {
622
- withImages.push(r);
623
- for (const img of r.__images || []) withImages.push(img);
777
+ for (const img of r.__images || []) images.push(img);
624
778
  delete r.__images;
625
779
  }
626
- 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
+ }
627
785
  }
628
786
 
629
787
  if (!silent) out(c.yellow(`\nStopped after ${maxIterations} steps — the safety limit for one request. Say "continue" to keep going.`));
@@ -643,7 +801,7 @@ export async function runAgent({
643
801
  * write and every shell command is refused no matter what the parent's mode is. A
644
802
  * subagent the user never sees must not be able to change the disk.
645
803
  */
646
- function makeSubagentTool({ client, model, cwd, signal, tui }) {
804
+ function makeSubagentTool({ client, model, cwd, signal, tui, permissions = null }) {
647
805
  return {
648
806
  name: 'task',
649
807
  needsPermission: false,
@@ -670,7 +828,7 @@ function makeSubagentTool({ client, model, cwd, signal, tui }) {
670
828
  messages,
671
829
  model,
672
830
  cwd: runCwd || cwd,
673
- permissions: createPermissions({ mode: 'plan' }),
831
+ permissions: permissions?.fork ? permissions.fork({ mode: 'plan', interactive: false }) : createPermissions({ mode: 'plan', interactive: false }),
674
832
  signal,
675
833
  tui: null,
676
834
  silent: true,
@@ -32,7 +32,7 @@ const TOOL_ICONS = {
32
32
 
33
33
  export function createPermissions({
34
34
  autoApprove = false, mode = 'default', onModeChange = null,
35
- allow = [], deny = [], onRulesChange = null,
35
+ allow = [], deny = [], onRulesChange = null, interactive = true,
36
36
  } = {}) {
37
37
  let current = autoApprove ? 'bypassPermissions' : (MODES.includes(mode) ? mode : mode === 'auto' ? 'bypassPermissions' : 'default');
38
38
  // Session rules live beside the saved ones and are never written to disk: "allow for
@@ -62,8 +62,23 @@ export function createPermissions({
62
62
  },
63
63
 
64
64
  /** What the rules say about this call, without asking. */
65
- verdict(toolName, input) {
66
- return decide({ allow: [...savedAllow, ...sessionAllow], deny: savedDeny }, toolName, input);
65
+ verdict(toolName, input, cwd = null) {
66
+ return decide({ allow: [...savedAllow, ...sessionAllow], deny: savedDeny }, toolName, input, { cwd });
67
+ },
68
+
69
+ /**
70
+ * The same rules, a different mode — for subagents and side commands (/review --fix,
71
+ * /btw, /test --fix). Those used to build fresh permissions with no user rules at
72
+ * all, so `deny run_command(git push:*)` did not apply inside them.
73
+ */
74
+ fork({ mode: nextMode = current, interactive: canAsk = interactive, allow: extraAllow = [] } = {}) {
75
+ return createPermissions({
76
+ mode: nextMode,
77
+ interactive: canAsk,
78
+ // Extra rules for this run only — /research allows web_fetch for itself.
79
+ allow: [...savedAllow, ...sessionAllow, ...extraAllow],
80
+ deny: savedDeny.filter((r) => !DEFAULT_DENY.includes(r)),
81
+ });
67
82
  },
68
83
 
69
84
  setMode(next) {
@@ -82,11 +97,11 @@ export function createPermissions({
82
97
  /**
83
98
  * @returns true to proceed. Throws PermissionDenied if the user says no.
84
99
  */
85
- async request(tool, input, { cwd, tui = null } = {}) {
100
+ async request(tool, input, { cwd, tui = null, signal = null } = {}) {
86
101
  // A deny rule outranks everything, including bypass. Someone who wrote
87
102
  // `deny: ["run_command(git push:*)"]` and then turned on bypass to get through a
88
103
  // long refactor did not mean "except that one".
89
- const verdict = api.verdict(tool.name, input);
104
+ const verdict = api.verdict(tool.name, input, cwd);
90
105
  if (verdict === 'deny') {
91
106
  throw new PermissionDenied(
92
107
  `A deny rule blocks ${tool.name}${subjectOf(tool.name, input) ? `: ${subjectOf(tool.name, input).slice(0, 80)}` : ''}. `
@@ -100,12 +115,17 @@ export function createPermissions({
100
115
 
101
116
  // Not a terminal — a script cannot answer, and silently proceeding would be the
102
117
  // worst possible reading of "no answer". In plan mode the same wall is the point.
103
- if (!process.stdin.isTTY) {
104
- throw new PermissionDenied(
118
+ // One-shot runs (-p, a task on the command line) are this case too even in a
119
+ // terminal: a prompt there wrote to stdout — into --output-format json — and waited
120
+ // for a key nobody saw.
121
+ if (!process.stdin.isTTY || !interactive) {
122
+ const err = new PermissionDenied(
105
123
  current === 'plan'
106
124
  ? 'Plan mode blocks every write and command. Shift+Tab out of plan mode, or run it with a mode that acts.'
107
- : `${tool.name} needs permission, but there is no terminal to ask. Re-run with --yes to allow it.`
125
+ : `${tool.name} needs permission, and this run cannot ask. Re-run with --yes, or --permission-mode acceptEdits for file edits only.`
108
126
  );
127
+ err.headless = true;
128
+ throw err;
109
129
  }
110
130
 
111
131
  // Inside the TUI the ask is an inline panel pinned above the prompt
@@ -133,6 +153,7 @@ export function createPermissions({
133
153
  pills: true,
134
154
  title: 'Permission',
135
155
  tone: tool.name === 'run_command' ? 'danger' : undefined,
156
+ signal,
136
157
  });
137
158
  const value = answer && typeof answer === 'object' ? answer.value : answer;
138
159
  const comment = (answer?.comment || '').trim();
@@ -151,14 +172,16 @@ export function createPermissions({
151
172
 
152
173
  out(` ${c.dim(`always → adds the rule ${c.bold(exact)}`)}`);
153
174
  const answer = await askKey(
154
- ` ${c.dim('[y] Allow once [s] this session [a] always [n] Reject ›')} `,
175
+ ` ${c.dim('[y] Allow once [s] this session [a] always [n] Reject (Enter) ›')} `,
155
176
  ['y', 's', 'a', 'n', '\r']
156
177
  );
157
178
  out('');
158
179
 
159
180
  if (answer === 's') { api.addRule(exact); return true; }
160
181
  if (answer === 'a') { api.addRule(exact, { persist: true }); return true; }
161
- if (answer === 'y' || answer === '\r') return true;
182
+ // Enter is the safe answer. It used to be "Allow once" — one stray key approved a
183
+ // command nobody had read.
184
+ if (answer === 'y') return true;
162
185
  throw new PermissionDenied(`You declined: ${tool.name}`);
163
186
  },
164
187
  };
@@ -176,13 +199,23 @@ function describeRule(rule) {
176
199
  /** The whole tool call as plain lines, for the TUI permission dialog. */
177
200
  function previewTextOf(tool, input) {
178
201
  const lines = [];
179
- if (tool.name === 'run_command') lines.push(`$ ${input.command}`);
180
- else if (tool.name === 'write_file') lines.push(...String(input.content ?? '').split('\n').slice(0, 12));
181
- else if (tool.name === 'edit_file') {
182
- for (const l of String(input.old ?? '').split('\n').slice(0, 6)) lines.push(`- ${l}`);
183
- for (const l of String(input.new ?? '').split('\n').slice(0, 6)) lines.push(`+ ${l}`);
184
- } else lines.push(JSON.stringify(input).slice(0, 200));
185
- return lines.slice(0, 12);
202
+ const more = (all, shown) => (all > shown ? [`… ${all - shown} more line${all - shown === 1 ? '' : 's'}`] : []);
203
+ // A command is shown WHOLE — every line of it (the dialog wraps and, if it must, shows
204
+ // the start and the end). Content previews are samples, and say how much is left out.
205
+ if (tool.name === 'run_command' || tool.name === 'background_run') lines.push(...`$ ${input.command}`.split('\n'));
206
+ else if (tool.name === 'write_file') {
207
+ const all = String(input.content ?? '').split('\n');
208
+ lines.push(...all.slice(0, 12), ...more(all.length, 12));
209
+ } else if (tool.name === 'edit_file') {
210
+ const olds = String(input.old ?? '').split('\n');
211
+ const news = String(input.new ?? '').split('\n');
212
+ for (const l of olds.slice(0, 6)) lines.push(`- ${l}`);
213
+ lines.push(...more(olds.length, 6).map((l) => `- ${l}`));
214
+ for (const l of news.slice(0, 6)) lines.push(`+ ${l}`);
215
+ lines.push(...more(news.length, 6).map((l) => `+ ${l}`));
216
+ } else if (tool.name === 'web_fetch') lines.push(String(input.url ?? ''));
217
+ else lines.push(JSON.stringify(input).slice(0, 400));
218
+ return lines;
186
219
  }
187
220
 
188
221
  function describe(tool, input, cwd) {