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 +34 -38
- package/bin/jaitus.js +14 -1
- package/package.json +2 -2
- package/src/agent/loop.js +234 -35
- 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/commands/update.js +78 -12
- package/src/config.js +12 -1
- package/src/errors.js +4 -0
- package/src/index.js +571 -119
- package/src/mcp.js +209 -72
- package/src/pendingUpdate.js +41 -0
- 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 +161 -0
- 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/widgets.js +6 -2
- package/src/tui-ink.js +2509 -1998
- package/src/tui.js +4 -0
- package/src/ui.js +5 -1
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,
|
|
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
|
-
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
|
|
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
|
-
|
|
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
|
+
"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
|
|
19
|
-
//
|
|
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 =
|
|
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)
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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
|
-
//
|
|
580
|
-
//
|
|
581
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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.',
|