jaitus 0.3.2 → 0.3.4
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 +1 -1
- package/bin/jaitus.js +6 -1
- package/package.json +2 -2
- package/src/agent/loop.js +48 -7
- package/src/api.js +7 -0
- package/src/commands/update.js +78 -12
- package/src/index.js +19 -7
- package/src/pendingUpdate.js +41 -0
- package/src/tui/clipboard.js +68 -0
- package/src/tui/widgets.js +6 -2
- package/src/tui-ink.js +2119 -1998
package/README.md
CHANGED
|
@@ -219,7 +219,7 @@ or pipes. Force either way with `JAITUS_FULLSCREEN=1` / `JAITUS_NO_FULLSCREEN=1`
|
|
|
219
219
|
the default; without a terminal to ask in, an action that needs permission is refused
|
|
220
220
|
rather than assumed.
|
|
221
221
|
- Commands run with no TTY, so anything interactive exits instead of hanging forever.
|
|
222
|
-
- The agent stops
|
|
222
|
+
- 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
223
|
- `jaitus doctor` never prints your sign-in token — only whether you are signed in.
|
|
224
224
|
|
|
225
225
|
## Signing in
|
package/bin/jaitus.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
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
|
+
import { waitForPendingUpdate } from '../src/pendingUpdate.js';
|
|
5
|
+
|
|
6
|
+
await waitForPendingUpdate().catch(() => false);
|
|
7
|
+
const { default: main } = await import('../src/index.js');
|
|
3
8
|
|
|
4
9
|
// Node 20 is the floor: the CLI uses global fetch, and on 18 it exists but streaming
|
|
5
10
|
// 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.4",
|
|
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",
|
|
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",
|
|
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,8 @@ 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;
|
|
33
38
|
|
|
34
39
|
// Room for a long answer or a large file in one reply. The models behind Jaitus reason
|
|
35
40
|
// before they answer, and that reasoning is spent from the same budget — at 8192 a long
|
|
@@ -295,8 +300,13 @@ export async function runAgent({
|
|
|
295
300
|
client, messages, model, cwd, permissions, signal, tui = null,
|
|
296
301
|
extraSystem = '', maxIterations = MAX_ITERATIONS, silent = false,
|
|
297
302
|
onText = null, onEvent = null, systemOverride = null, journal = null,
|
|
298
|
-
subagents = true,
|
|
303
|
+
subagents = true, onContext = null,
|
|
299
304
|
}) {
|
|
305
|
+
// Loop detection. A call is identified by its tool, its input, and how many files had
|
|
306
|
+
// been changed when it was made — so edit → test → edit → test is progress (the count
|
|
307
|
+
// moves), while reading the same file over and over is not.
|
|
308
|
+
let changes = 0;
|
|
309
|
+
const repeats = new Map();
|
|
300
310
|
// input = fresh input, cached = prompt-cache reads (billed far cheaper), and
|
|
301
311
|
// context = the real size of the last prompt sent, as the provider counted it.
|
|
302
312
|
const totals = { input: 0, cached: 0, output: 0, tools: 0, context: 0 };
|
|
@@ -398,7 +408,13 @@ export async function runAgent({
|
|
|
398
408
|
totals.cached += (turn.usage.cache_read_input_tokens || 0) + (turn.usage.cache_creation_input_tokens || 0);
|
|
399
409
|
totals.output += turn.usage.output_tokens || 0;
|
|
400
410
|
const promptSize = (turn.usage.input_tokens || 0) + (turn.usage.cache_read_input_tokens || 0) + (turn.usage.cache_creation_input_tokens || 0);
|
|
401
|
-
if (promptSize)
|
|
411
|
+
if (promptSize) {
|
|
412
|
+
totals.context = promptSize + (turn.usage.output_tokens || 0);
|
|
413
|
+
// The header's context meter moves after every step. It used to be told only when
|
|
414
|
+
// the whole run returned, so through a long task — and after one stopped with
|
|
415
|
+
// Esc or an error — it sat at 0.
|
|
416
|
+
try { onContext?.(totals.context); } catch { /* the meter never breaks a turn */ }
|
|
417
|
+
}
|
|
402
418
|
// The work bar's live count. It was declared and drawn but never fed, so it never
|
|
403
419
|
// appeared; now it moves after every step of the turn.
|
|
404
420
|
tui?.setTurnTokens?.(totals.input + totals.cached + totals.output);
|
|
@@ -447,6 +463,27 @@ export async function runAgent({
|
|
|
447
463
|
|
|
448
464
|
if (!calls.length) { tui?.bellIfAway?.(); return { ...totals, stopReason: turn.stopReason }; }
|
|
449
465
|
|
|
466
|
+
// The same call, same input, nothing changed since: STUCK_REPEATS times is a loop.
|
|
467
|
+
// Checked before running, so the repeat that proves it is not paid for again.
|
|
468
|
+
const stuckOn = calls.find((call) => {
|
|
469
|
+
const key = `${changes}\u0000${call.name}\u0000${JSON.stringify(call.input ?? null)}`;
|
|
470
|
+
const n = (repeats.get(key) || 0) + 1;
|
|
471
|
+
repeats.set(key, n);
|
|
472
|
+
return n >= STUCK_REPEATS;
|
|
473
|
+
});
|
|
474
|
+
if (stuckOn) {
|
|
475
|
+
// Every tool_use still gets a result — the model is told why these did not run, so
|
|
476
|
+
// after "continue" it does not simply make the same call a fifth time.
|
|
477
|
+
messages.push({ role: 'user', content: calls.map((call) => result(call.id,
|
|
478
|
+
`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)) });
|
|
479
|
+
const label = toolMap[stuckOn.name]?.summarise ? toolMap[stuckOn.name].summarise(stuckOn.input) : stuckOn.name;
|
|
480
|
+
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".`;
|
|
481
|
+
if (tui?.notice) tui.notice(why, 'warn');
|
|
482
|
+
else if (!silent) out(c.yellow(`\n${why}`));
|
|
483
|
+
tui?.bellIfAway?.();
|
|
484
|
+
return { ...totals, stopReason: 'stuck' };
|
|
485
|
+
}
|
|
486
|
+
|
|
450
487
|
// Every call gets a result, whatever happened.
|
|
451
488
|
const results = [];
|
|
452
489
|
for (const call of calls) {
|
|
@@ -529,6 +566,8 @@ export async function runAgent({
|
|
|
529
566
|
tui?.recordActivity?.({ id: activityId, name: call.name, label, status: 'ok', detail: String(output).slice(0, 2000) });
|
|
530
567
|
onEvent?.({ type: 'tool_result', name: call.name, input: call.input, ok: true, output });
|
|
531
568
|
results.push(result(call.id, output, false));
|
|
569
|
+
// A changed file is progress: calls made after it are not repeats of those before.
|
|
570
|
+
if (['write_file', 'edit_file'].includes(call.name)) changes += 1;
|
|
532
571
|
// read_image: the pixels ride along as an image block in the tool-result
|
|
533
572
|
// message, so the model actually sees the picture instead of a caption of it.
|
|
534
573
|
if (call.name === 'read_image' && !silent) {
|
|
@@ -587,7 +626,7 @@ export async function runAgent({
|
|
|
587
626
|
messages.push({ role: 'user', content: withImages });
|
|
588
627
|
}
|
|
589
628
|
|
|
590
|
-
if (!silent) out(c.yellow(`\nStopped after ${maxIterations} steps
|
|
629
|
+
if (!silent) out(c.yellow(`\nStopped after ${maxIterations} steps — the safety limit for one request. Say "continue" to keep going.`));
|
|
591
630
|
tui?.bellIfAway?.();
|
|
592
631
|
return { ...totals, stopReason: 'max_iterations' };
|
|
593
632
|
}
|
|
@@ -636,7 +675,9 @@ function makeSubagentTool({ client, model, cwd, signal, tui }) {
|
|
|
636
675
|
tui: null,
|
|
637
676
|
silent: true,
|
|
638
677
|
subagents: false,
|
|
639
|
-
|
|
678
|
+
// Read-only research across a codebase is many small reads; 20 cut it off
|
|
679
|
+
// mid-search. The stuck detector is what catches a loop.
|
|
680
|
+
maxIterations: 100,
|
|
640
681
|
extraSystem: '\n\nYou are a subagent. You cannot ask questions and nobody will read your intermediate '
|
|
641
682
|
+ 'steps — only your final message. End with the findings themselves, with exact file paths and line '
|
|
642
683
|
+ 'numbers where they apply, not a description of how you looked.',
|
package/src/api.js
CHANGED
|
@@ -10,6 +10,10 @@ import { fromGatewayBody, fromNetwork, JaitusError } from './errors.js';
|
|
|
10
10
|
|
|
11
11
|
export class NotSignedIn extends Error {}
|
|
12
12
|
|
|
13
|
+
// The context window the gateway last advertised, or null before it has been asked.
|
|
14
|
+
let contextWindowSeen = null;
|
|
15
|
+
export const advertisedContextWindow = () => contextWindowSeen;
|
|
16
|
+
|
|
13
17
|
export function createClient() {
|
|
14
18
|
const { baseUrl } = endpoints();
|
|
15
19
|
if (!signedIn()) throw new NotSignedIn('Not signed in. Run `jaitus login` first.');
|
|
@@ -63,6 +67,9 @@ export function createClient() {
|
|
|
63
67
|
const body = await res.json().catch(() => ({}));
|
|
64
68
|
if (!res.ok) throw fromGatewayBody(res.status, body, { requestId: res.headers.get('x-request-id') || '' });
|
|
65
69
|
const models = (body?.data || []).map((m) => m?.id).filter(Boolean);
|
|
70
|
+
// How much conversation fits, as the gateway computed it for this account.
|
|
71
|
+
const ctx = (body?.data || []).map((m) => Number(m?.context_window)).find((n) => n > 0);
|
|
72
|
+
if (ctx) contextWindowSeen = ctx;
|
|
66
73
|
// An empty list is a service fault, not something the user can fix — and naming
|
|
67
74
|
// the gateway's model map to them would be naming our internals.
|
|
68
75
|
if (!models.length) throw new JaitusError({ code: 'unavailable', status: 502 });
|
package/src/commands/update.js
CHANGED
|
@@ -16,6 +16,7 @@ import { spawn } from 'node:child_process';
|
|
|
16
16
|
import { fileURLToPath } from 'node:url';
|
|
17
17
|
import { c, out, ok, info, warn, fail, present, askKey } from '../ui.js';
|
|
18
18
|
import { version, HOME } from '../config.js';
|
|
19
|
+
import { RUNNING_FILE } from '../pendingUpdate.js';
|
|
19
20
|
|
|
20
21
|
const REGISTRY = process.env.JAITUS_REGISTRY || 'https://registry.npmjs.org';
|
|
21
22
|
const TIMEOUT_MS = 5000;
|
|
@@ -108,6 +109,33 @@ function run(cmd, args, { capture = false } = {}) {
|
|
|
108
109
|
});
|
|
109
110
|
}
|
|
110
111
|
|
|
112
|
+
/**
|
|
113
|
+
* `npm install -g jaitus@<version>`, run so that it cannot be cut off halfway.
|
|
114
|
+
*
|
|
115
|
+
* A global install takes 30–60s (measured: 46s on Windows), and a Ctrl+C in the
|
|
116
|
+
* terminal reaches every process attached to it — npm killed mid-install can leave the
|
|
117
|
+
* package half-replaced and `jaitus` unable to start. So npm runs in its own process
|
|
118
|
+
* group (detached; windowsHide keeps Windows from opening a console for it) and carries
|
|
119
|
+
* on to the end whatever happens to this process. `onTick` gets the seconds elapsed.
|
|
120
|
+
*/
|
|
121
|
+
export function installGlobal(v, { onTick = () => {}, onStart = () => {} } = {}) {
|
|
122
|
+
return new Promise((resolve) => {
|
|
123
|
+
const args = ['install', '-g', `jaitus@${v}`, '--no-audit', '--no-fund', '--loglevel=error'];
|
|
124
|
+
const opts = { stdio: ['ignore', 'ignore', 'pipe'], detached: true, windowsHide: true };
|
|
125
|
+
const child = process.platform === 'win32'
|
|
126
|
+
? spawn(['npm', ...args].join(' '), { ...opts, shell: true })
|
|
127
|
+
: spawn('npm', args, opts);
|
|
128
|
+
if (child.pid) onStart(child.pid);
|
|
129
|
+
let stderr = '';
|
|
130
|
+
child.stderr?.on('data', (d) => { stderr = (stderr + d).slice(-4000); });
|
|
131
|
+
const t0 = Date.now();
|
|
132
|
+
const ticker = setInterval(() => onTick(Math.round((Date.now() - t0) / 1000)), 1000);
|
|
133
|
+
const finish = (code) => { clearInterval(ticker); resolve({ code, stderr: stderr.trim() }); };
|
|
134
|
+
child.on('error', () => finish(-1));
|
|
135
|
+
child.on('close', (code) => finish(code));
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
111
139
|
/**
|
|
112
140
|
* How this copy was installed: 'npm-global', 'npx', or 'other'.
|
|
113
141
|
*
|
|
@@ -158,8 +186,10 @@ export function isNewer(a, b) {
|
|
|
158
186
|
* Returns the child's exit code when it relaunched, or null to carry on in this process.
|
|
159
187
|
*/
|
|
160
188
|
export async function autoUpdate({ argv = [], settings = {}, env = process.env, current = version(), log = out,
|
|
161
|
-
check = () => checkRegistry(), method = () => installMethod(), install =
|
|
162
|
-
relaunch = defaultRelaunch, now = Date.now(), failFile = path.join(HOME, 'update-failed.json')
|
|
189
|
+
check = () => checkRegistry(), method = () => installMethod(), install = null,
|
|
190
|
+
relaunch = defaultRelaunch, now = Date.now(), failFile = path.join(HOME, 'update-failed.json'),
|
|
191
|
+
cacheFile = CHECK_FILE, waitMs = 1500, write = (s) => process.stdout.write(s),
|
|
192
|
+
runningFile = RUNNING_FILE } = {}) {
|
|
163
193
|
if (env.JAITUS_UPDATED_FROM) return null; // just relaunched: never loop
|
|
164
194
|
if (!updateCheckEnabled(env) || env.JAITUS_NO_AUTOUPDATE || settings.autoUpdate === false) return null;
|
|
165
195
|
if (!process.stdout.isTTY || !process.stdin.isTTY) return null; // scripts and pipes get a stable version
|
|
@@ -170,22 +200,58 @@ export async function autoUpdate({ argv = [], settings = {}, env = process.env,
|
|
|
170
200
|
if (now - Number(failed.at) < DAY_MS) return null;
|
|
171
201
|
} catch { /* no recent failure */ }
|
|
172
202
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
203
|
+
// What the last check found. A check that outlived its wait on a slow connection still
|
|
204
|
+
// finishes and records its answer here, so the NEXT launch updates without waiting —
|
|
205
|
+
// before this, a network slower than the wait skipped the update on every launch.
|
|
206
|
+
const remember = (latest) => {
|
|
207
|
+
try { fs.mkdirSync(path.dirname(cacheFile), { recursive: true }); fs.writeFileSync(cacheFile, JSON.stringify({ checkedAt: Date.now(), latest: latest || null })); } catch { /* read-only home */ }
|
|
208
|
+
};
|
|
209
|
+
let latest = null;
|
|
210
|
+
try {
|
|
211
|
+
const cached = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
|
|
212
|
+
if (cached?.latest && isNewer(cached.latest, current) && now - Number(cached.checkedAt) < 6 * 3600e3) latest = cached.latest;
|
|
213
|
+
} catch { /* no earlier answer */ }
|
|
214
|
+
if (!latest) {
|
|
215
|
+
const asked = Promise.resolve().then(check).then((r) => { if (r?.version) remember(r.version); return r; }, () => ({ version: null }));
|
|
216
|
+
({ version: latest } = await Promise.race([
|
|
217
|
+
asked,
|
|
218
|
+
// unref: a quick command must not sit waiting for this timer after the answer came.
|
|
219
|
+
new Promise((resolve) => setTimeout(() => resolve({ version: null }), waitMs).unref()),
|
|
220
|
+
]));
|
|
221
|
+
}
|
|
178
222
|
if (!latest || !isNewer(latest, current)) return null;
|
|
179
223
|
if ((await method()) !== 'npm-global') return null; // the in-app notice covers these
|
|
180
224
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
225
|
+
const head = ` ${c.brand('Jaitus')} ${c.dim(`v${current} →`)} ${c.bold(`v${latest}`)}`;
|
|
226
|
+
write(`${head} ${c.dim('— updating…')}`);
|
|
227
|
+
// Ctrl+C now leaves npm to finish on its own (it is detached) — say so rather than
|
|
228
|
+
// leaving someone to wonder whether they broke the install. Runs before the bin's own
|
|
229
|
+
// SIGINT handler, which exits.
|
|
230
|
+
const onInterrupt = () => {
|
|
231
|
+
write(`\n ${c.dim(`The update keeps installing in the background. Start ${c.bold('jaitus')} again in a minute.`)}\n`);
|
|
232
|
+
};
|
|
233
|
+
process.prependListener('SIGINT', onInterrupt);
|
|
234
|
+
const doInstall = install || ((v) => installGlobal(v, {
|
|
235
|
+
onTick: (s) => write(`\r${head} ${c.dim(`— updating… ${s}s (this takes about a minute)`)}`),
|
|
236
|
+
// Left behind only if this process is gone before npm is — the next launch waits on it.
|
|
237
|
+
onStart: (pid) => { try { fs.mkdirSync(path.dirname(runningFile), { recursive: true }); fs.writeFileSync(runningFile, JSON.stringify({ pid, at: Date.now(), version: v })); } catch { /* best effort */ } },
|
|
238
|
+
}));
|
|
239
|
+
let result;
|
|
240
|
+
try { result = await doInstall(latest); } finally {
|
|
241
|
+
process.removeListener('SIGINT', onInterrupt);
|
|
242
|
+
try { fs.rmSync(runningFile, { force: true }); } catch { /* best effort */ }
|
|
243
|
+
}
|
|
244
|
+
write('\n');
|
|
245
|
+
if (result.code !== 0) {
|
|
184
246
|
try { fs.mkdirSync(path.dirname(failFile), { recursive: true }); fs.writeFileSync(failFile, JSON.stringify({ at: now, version: latest })); } catch { /* best effort */ }
|
|
185
|
-
|
|
247
|
+
const needsSudo = /EACCES|permission denied/i.test(result.stderr || '');
|
|
248
|
+
log(` ${c.yellow('!')} ${c.dim('Could not update automatically — continuing on')} v${current}${c.dim('. To update:')} ${c.bold(process.platform !== 'win32' && needsSudo ? 'sudo npm install -g jaitus@latest' : 'npm install -g jaitus@latest')}`);
|
|
249
|
+
const reason = (result.stderr || '').split('\n').map((l) => l.trim()).filter(Boolean).find((l) => /ERR|EACCES|EPERM|EBUSY|ENOTEMPTY/i.test(l));
|
|
250
|
+
if (reason) log(` ${c.dim(reason.slice(0, 200))}`);
|
|
186
251
|
return null;
|
|
187
252
|
}
|
|
188
|
-
|
|
253
|
+
remember(latest);
|
|
254
|
+
log(` ${c.accent('✓')} ${c.dim(`Updated to v${latest} — reopening`)}`);
|
|
189
255
|
return relaunch({ ...env, JAITUS_UPDATED_FROM: current });
|
|
190
256
|
}
|
|
191
257
|
|
package/src/index.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import path from 'node:path';
|
|
10
10
|
import fs from 'node:fs';
|
|
11
11
|
import { spawn, spawnSync } from 'node:child_process';
|
|
12
|
-
import { createClient, NotSignedIn } from './api.js';
|
|
12
|
+
import { createClient, NotSignedIn, advertisedContextWindow } from './api.js';
|
|
13
13
|
import { fromResponse, isJaitusError } from './errors.js';
|
|
14
14
|
import { signedIn } from './session.js';
|
|
15
15
|
import {
|
|
@@ -81,7 +81,7 @@ const FLAG_NAMES = ['--print', '-p', '--continue', '-c', '--resume', '-r', '--ou
|
|
|
81
81
|
function parseArgs(argv) {
|
|
82
82
|
const flags = {
|
|
83
83
|
model: null, yes: false, cwd: process.cwd(), json: false, help: false, version: false,
|
|
84
|
-
print: false, continue: false, resume: null, outputFormat: 'text', maxTurns:
|
|
84
|
+
print: false, continue: false, resume: null, outputFormat: 'text', maxTurns: undefined,
|
|
85
85
|
permissionMode: 'default', systemPrompt: '', appendSystemPrompt: '',
|
|
86
86
|
};
|
|
87
87
|
const rest = [];
|
|
@@ -97,7 +97,7 @@ function parseArgs(argv) {
|
|
|
97
97
|
else if (a === '--continue' || a === '-c') flags.continue = true;
|
|
98
98
|
else if (a === '--resume' || a === '-r') flags.resume = argv[++i];
|
|
99
99
|
else if (a === '--output-format') flags.outputFormat = argv[++i] || 'text';
|
|
100
|
-
else if (a === '--max-turns') flags.maxTurns = Math.max(1, Number(argv[++i]) ||
|
|
100
|
+
else if (a === '--max-turns') flags.maxTurns = Math.max(1, Number(argv[++i]) || 1000);
|
|
101
101
|
else if (a === '--permission-mode') flags.permissionMode = argv[++i] || 'default';
|
|
102
102
|
else if (a === '--dangerously-skip-permissions') flags.permissionMode = 'bypassPermissions';
|
|
103
103
|
else if (a === '--system-prompt') flags.systemPrompt = argv[++i] || '';
|
|
@@ -300,10 +300,18 @@ function gitBranch(cwd) {
|
|
|
300
300
|
}
|
|
301
301
|
}
|
|
302
302
|
|
|
303
|
-
//
|
|
304
|
-
//
|
|
305
|
-
//
|
|
306
|
-
const
|
|
303
|
+
// How much conversation a session carries before it is compacted. The gateway works it
|
|
304
|
+
// out from the providers' real limits and the admin's cap, and advertises it on its
|
|
305
|
+
// model list; this floor is only used until that answer arrives, or if it never does.
|
|
306
|
+
const CONTEXT_FALLBACK = 128_000;
|
|
307
|
+
let CONTEXT_BUDGET = CONTEXT_FALLBACK;
|
|
308
|
+
async function loadContextBudget(client) {
|
|
309
|
+
try {
|
|
310
|
+
if (!advertisedContextWindow()) await client.models();
|
|
311
|
+
CONTEXT_BUDGET = advertisedContextWindow() || CONTEXT_FALLBACK;
|
|
312
|
+
} catch { /* keep the floor */ }
|
|
313
|
+
return CONTEXT_BUDGET;
|
|
314
|
+
}
|
|
307
315
|
|
|
308
316
|
// Compact at this fraction of the budget. Low enough that the summarisation request
|
|
309
317
|
// itself still fits comfortably, high enough that a normal session never sees it.
|
|
@@ -548,6 +556,7 @@ async function session({ flags }) {
|
|
|
548
556
|
let [model, device] = await Promise.all([
|
|
549
557
|
resolveModel(client, flags.model),
|
|
550
558
|
probeDevice({}).catch(() => ({ vt: !!process.stdout.isTTY, sixel: false })),
|
|
559
|
+
loadContextBudget(client),
|
|
551
560
|
]);
|
|
552
561
|
// Degrade on EVIDENCE, not on silence.
|
|
553
562
|
//
|
|
@@ -769,9 +778,12 @@ async function session({ flags }) {
|
|
|
769
778
|
const ctrl = new AbortController();
|
|
770
779
|
activeAbort = ctrl;
|
|
771
780
|
const turnStarted = Date.now();
|
|
781
|
+
// The new message counts toward the context from the moment it is sent.
|
|
782
|
+
try { showContext(); } catch { /* header keeps last value */ }
|
|
772
783
|
try {
|
|
773
784
|
const result = await runAgent({
|
|
774
785
|
client, messages, model, cwd: flags.cwd, permissions, signal: ctrl.signal, tui, journal,
|
|
786
|
+
onContext: (n) => { lastContext = n; sessionState.lastContext = n; showContext(); },
|
|
775
787
|
extraSystem: `${skillCatalog(skills)}\n\nCurrent intent: ${intentMode}. ${intentGuidance[intentMode]}${flags.appendSystemPrompt ? `\n\nAdditional instructions:\n${flags.appendSystemPrompt}` : ''}`,
|
|
776
788
|
systemOverride: flags.systemPrompt || null,
|
|
777
789
|
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// ─── An update still installing ─────────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Loaded by bin/jaitus.js BEFORE the app, and so only from Node's own modules.
|
|
4
|
+
//
|
|
5
|
+
// A launch that is closed with Ctrl+C during its auto-update leaves npm to finish on its
|
|
6
|
+
// own (commands/update.js runs it detached so it cannot be cut off halfway). npm writes
|
|
7
|
+
// the new package.json before it has finished unpacking the dependencies, so a launch in
|
|
8
|
+
// those seconds would load a half-installed copy — measured: ERR_UNSUPPORTED_DIR_IMPORT
|
|
9
|
+
// from ink, a crash before any of our code runs. So the launcher waits for that npm to
|
|
10
|
+
// exit (up to 3 minutes), then loads the app, which is by then the new version.
|
|
11
|
+
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import os from 'node:os';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
|
|
16
|
+
export const RUNNING_FILE = path.join(os.homedir(), '.jaitus', 'update-running.json');
|
|
17
|
+
|
|
18
|
+
export function pidAlive(pid) {
|
|
19
|
+
try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function waitForPendingUpdate({ file = RUNNING_FILE, alive = pidAlive, write = (s) => process.stderr.write(s), maxMs = 180e3, pollMs = 500 } = {}) {
|
|
23
|
+
let running;
|
|
24
|
+
try { running = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return false; }
|
|
25
|
+
// Stale (a crashed machine, a reused pid long after): ignore it and clear it.
|
|
26
|
+
if (!running?.pid || !alive(running.pid) || Date.now() - Number(running.at) > 10 * 60e3) {
|
|
27
|
+
try { fs.rmSync(file, { force: true }); } catch { /* best effort */ }
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
const tty = process.stderr.isTTY;
|
|
31
|
+
const head = ` Jaitus — finishing the update to v${running.version}…`;
|
|
32
|
+
write(head);
|
|
33
|
+
const t0 = Date.now();
|
|
34
|
+
while (alive(running.pid) && Date.now() - t0 < maxMs) {
|
|
35
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
36
|
+
if (tty) write(`\r${head} ${Math.round((Date.now() - t0) / 1000)}s`);
|
|
37
|
+
}
|
|
38
|
+
write('\n');
|
|
39
|
+
try { fs.rmSync(file, { force: true }); } catch { /* best effort */ }
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// ─── Reading the system clipboard ───────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// The app takes the mouse, so a terminal's own right-click paste never happens — the
|
|
4
|
+
// click is reported to the app instead, and it used to be thrown away: "paste does not
|
|
5
|
+
// work". Ctrl+V is the terminal's in some terminals and the app's in others. So the app
|
|
6
|
+
// reads the clipboard itself for right-click and Ctrl+V, and for Alt+V (an image, which
|
|
7
|
+
// no terminal can type in).
|
|
8
|
+
//
|
|
9
|
+
// Asynchronous on purpose: PowerShell takes a few hundred milliseconds to start, and a
|
|
10
|
+
// synchronous read froze the whole interface for that long.
|
|
11
|
+
|
|
12
|
+
import { spawn } from 'node:child_process';
|
|
13
|
+
|
|
14
|
+
function capture(cmd, args, { timeout = 4000, binary = false } = {}) {
|
|
15
|
+
return new Promise((resolve) => {
|
|
16
|
+
let child;
|
|
17
|
+
try { child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }); } catch { resolve(null); return; }
|
|
18
|
+
const chunks = [];
|
|
19
|
+
const timer = setTimeout(() => { try { child.kill(); } catch { /* gone */ } resolve(null); }, timeout);
|
|
20
|
+
timer.unref?.();
|
|
21
|
+
child.stdout.on('data', (d) => chunks.push(d));
|
|
22
|
+
child.on('error', () => { clearTimeout(timer); resolve(null); });
|
|
23
|
+
child.on('close', (code) => {
|
|
24
|
+
clearTimeout(timer);
|
|
25
|
+
if (code !== 0) return resolve(null);
|
|
26
|
+
const buf = Buffer.concat(chunks);
|
|
27
|
+
resolve(binary ? buf : buf.toString('utf8'));
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Plain text on the clipboard, or null (empty, not text, or no clipboard tool). */
|
|
33
|
+
export async function readClipboardText({ platform = process.platform, run = capture } = {}) {
|
|
34
|
+
let text = null;
|
|
35
|
+
if (platform === 'win32') {
|
|
36
|
+
// UTF-8 out, or anything outside the console code page (quotes, dashes, non-Latin
|
|
37
|
+
// text) arrives as '?'. -Raw keeps line breaks as they were copied.
|
|
38
|
+
text = await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command',
|
|
39
|
+
'[Console]::OutputEncoding=[Text.Encoding]::UTF8; $t = Get-Clipboard -Raw; if ($null -ne $t) { [Console]::Out.Write($t) }']);
|
|
40
|
+
} else if (platform === 'darwin') {
|
|
41
|
+
text = await run('pbpaste', []);
|
|
42
|
+
} else {
|
|
43
|
+
for (const [cmd, args] of [['wl-paste', ['--no-newline']], ['xclip', ['-selection', 'clipboard', '-o']], ['xsel', ['--clipboard', '--output']]]) {
|
|
44
|
+
text = await run(cmd, args);
|
|
45
|
+
if (text != null) break;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return text ? text : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** PNG bytes of an image on the clipboard, or null. */
|
|
52
|
+
export async function readClipboardImage({ platform = process.platform, run = capture } = {}) {
|
|
53
|
+
if (platform === 'win32') {
|
|
54
|
+
const b64 = await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command',
|
|
55
|
+
'Add-Type -AssemblyName System.Windows.Forms,System.Drawing; $i = [Windows.Forms.Clipboard]::GetImage(); if (!$i) { exit 1 }; $m = New-Object IO.MemoryStream; $i.Save($m, [Drawing.Imaging.ImageFormat]::Png); [Console]::Out.Write([Convert]::ToBase64String($m.ToArray()))'],
|
|
56
|
+
{ timeout: 8000 });
|
|
57
|
+
return b64 && b64.trim() ? Buffer.from(b64.trim(), 'base64') : null;
|
|
58
|
+
}
|
|
59
|
+
if (platform === 'darwin') {
|
|
60
|
+
const bytes = await run('pngpaste', ['-'], { timeout: 8000, binary: true });
|
|
61
|
+
return bytes?.length ? bytes : null;
|
|
62
|
+
}
|
|
63
|
+
for (const [cmd, args] of [['wl-paste', ['--type', 'image/png']], ['xclip', ['-selection', 'clipboard', '-t', 'image/png', '-o']]]) {
|
|
64
|
+
const bytes = await run(cmd, args, { timeout: 8000, binary: true });
|
|
65
|
+
if (bytes?.length) return bytes;
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
package/src/tui/widgets.js
CHANGED
|
@@ -194,8 +194,12 @@ export function Reveal({ children, rows, duration = 140 }) {
|
|
|
194
194
|
/** Rows of spans → Ink text. The single place span objects become pixels. */
|
|
195
195
|
export function Rows({ rows, maxRows }) {
|
|
196
196
|
const visible = maxRows && rows.length > maxRows ? rows.slice(rows.length - maxRows) : rows;
|
|
197
|
-
|
|
198
|
-
|
|
197
|
+
// flexShrink 0, on the column and on every row: inside a box with a fixed height,
|
|
198
|
+
// Yoga otherwise SHRINKS rows to fit, and shrunk rows in a terminal are drawn on top
|
|
199
|
+
// of each other — two lines of a reply painted onto one ("while it arrives.is a
|
|
200
|
+
// reply…", measured). Rows that do not fit must be clipped by the parent, never merged.
|
|
201
|
+
return h(Box, { flexDirection: 'column', flexShrink: 0 }, visible.map((row, index) =>
|
|
202
|
+
h(Text, { key: index, wrap: 'truncate-end', flexShrink: 0 }, row.length
|
|
199
203
|
? row.map((span, i) => h(Text, {
|
|
200
204
|
key: i,
|
|
201
205
|
color: span.color,
|