jaitus 0.3.4 → 0.3.6
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 +35 -37
- package/bin/jaitus.js +8 -0
- package/package.json +2 -2
- package/src/agent/loop.js +186 -28
- package/src/agent/permissions.js +50 -17
- package/src/agent/rules.js +85 -14
- package/src/agent/tools.js +266 -50
- package/src/background.js +34 -7
- package/src/commands/commit.js +63 -46
- package/src/commands/doctor.js +1 -1
- package/src/commands/explain.js +5 -5
- package/src/commands/logout.js +2 -0
- package/src/commands/research.js +5 -6
- package/src/commands/review.js +8 -11
- package/src/commands/test.js +18 -14
- package/src/config.js +12 -1
- package/src/errors.js +4 -0
- package/src/index.js +567 -118
- package/src/mcp.js +209 -72
- package/src/project.js +0 -0
- package/src/session.js +18 -0
- package/src/sessions.js +20 -2
- package/src/terminal.js +46 -11
- package/src/tui/ansi.js +3 -1
- package/src/tui/clipboard.js +100 -7
- package/src/tui/entries.js +5 -5
- package/src/tui/highlight.js +4 -4
- package/src/tui/markdown.js +1 -1
- package/src/tui/theme.js +8 -0
- package/src/tui-ink.js +510 -106
- package/src/tui.js +4 -0
- package/src/ui.js +5 -1
package/README.md
CHANGED
|
@@ -94,49 +94,47 @@ 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,
|
|
98
|
-
| `/scroll-speed [n]` | mouse-wheel speed, 0.25–
|
|
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,
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
`
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
`
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
`
|
|
136
|
-
|
|
137
|
-
|
|
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 (`Alt+V` on Windows — Windows Terminal and
|
|
123
|
+
VS Code keep `Ctrl+V` for themselves; `Ctrl+V` on macOS and Linux, where `Cmd+V` /
|
|
124
|
+
`Ctrl+Shift+V` belong to the terminal), type `/image path.png`, or
|
|
125
|
+
mention `@screenshot.png` — each becomes an `[Image #N]` chip listed under the
|
|
126
|
+
composer. Backspace over a chip removes it; only images whose chip is still in the
|
|
127
|
+
message are sent. Screenshots over 3.5 MB are shrunk automatically.
|
|
128
|
+
|
|
129
|
+
Scrolling stays inside the app: `PgUp`/`PgDn`, `Ctrl+Home`/`Ctrl+End`, the mouse wheel.
|
|
130
|
+
Scrolling up pauses auto-follow; `Ctrl+End` (or a click in the conversation) returns
|
|
131
|
+
to the live end. Panels (`/diff`, `/tasks`, `/help`) scroll with the arrows, PgUp/PgDn
|
|
132
|
+
and the wheel.
|
|
133
|
+
|
|
134
|
+
Drag selects and copies on release; double-click selects a word, triple-click a line.
|
|
135
|
+
`jaitus config copyOnSelect off` turns auto-copy off — then `Ctrl+C` copies.
|
|
136
|
+
`JAITUS_DISABLE_MOUSE=1` gives the terminal its own selection back. Hold `Shift`
|
|
137
|
+
(`Option` in iTerm2) for a one-off native selection.
|
|
140
138
|
|
|
141
139
|
## Options
|
|
142
140
|
|
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.
|
|
3
|
+
"version": "0.3.6",
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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
|
-
|
|
574
|
-
|
|
575
|
-
|
|
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
|
-
//
|
|
619
|
-
//
|
|
620
|
-
|
|
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
|
-
|
|
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:
|
|
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,
|
package/src/agent/permissions.js
CHANGED
|
@@ -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
|
-
|
|
104
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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) {
|