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/src/commands/commit.js
CHANGED
|
@@ -1,71 +1,88 @@
|
|
|
1
1
|
// ─── /commit — review the diff, write a real message, land it ───────────────────
|
|
2
2
|
//
|
|
3
|
-
// Shows what changed, asks the model to summarise the WHY into a subject line,
|
|
4
|
-
//
|
|
3
|
+
// Shows what changed, asks the model to summarise the WHY into a subject line, then
|
|
4
|
+
// asks before committing. Pushing is always a separate, explicit choice — never implicit.
|
|
5
|
+
//
|
|
6
|
+
// /commit the model proposes a subject; you confirm
|
|
7
|
+
// /commit fix the login bug your subject, used as written; you still confirm
|
|
8
|
+
//
|
|
9
|
+
// The previous version could never commit through its normal path: it did not await
|
|
10
|
+
// its own prompts (so the answer was a Promise, never 'y'), read the subject from a
|
|
11
|
+
// field the agent does not return, and ran git in the process directory rather than the
|
|
12
|
+
// session's --cwd.
|
|
5
13
|
import { spawnSync } from 'node:child_process';
|
|
6
14
|
import { runAgent } from '../agent/loop.js';
|
|
7
15
|
import { createPermissions } from '../agent/permissions.js';
|
|
8
16
|
import { c, out, ok, warn, fail, askKey, glyph } from '../ui.js';
|
|
9
17
|
|
|
10
|
-
function git(args,
|
|
11
|
-
const r = spawnSync('git', args, { encoding: 'utf8',
|
|
18
|
+
function git(args, cwd) {
|
|
19
|
+
const r = spawnSync('git', args, { encoding: 'utf8', cwd, windowsHide: true });
|
|
12
20
|
return { ok: r.status === 0, out: (r.stdout || '').trim(), err: (r.stderr || '').trim() };
|
|
13
21
|
}
|
|
14
22
|
|
|
23
|
+
/** A choice, through the app's dialog when there is one, else a key prompt. */
|
|
24
|
+
async function choose(ctx, question, options) {
|
|
25
|
+
if (ctx.tui?.askDialog) {
|
|
26
|
+
const r = await ctx.tui.askDialog(question, options.map(([value, label]) => ({ value, label })), [], { title: 'COMMIT', hideComment: true });
|
|
27
|
+
return r && typeof r === 'object' ? r.value : r;
|
|
28
|
+
}
|
|
29
|
+
if (!process.stdin.isTTY) return null;
|
|
30
|
+
const keys = options.map(([value]) => value[0]);
|
|
31
|
+
const key = await askKey(` ${question} ${options.map(([v, l]) => `(${v[0]}) ${l}`).join(' / ')}`, keys);
|
|
32
|
+
return options.find(([value]) => value[0] === key)?.[0] ?? null;
|
|
33
|
+
}
|
|
34
|
+
|
|
15
35
|
export default async function commit(args, ctx) {
|
|
16
|
-
|
|
36
|
+
const cwd = ctx.flags?.cwd || process.cwd();
|
|
37
|
+
if (!git(['rev-parse', '--is-inside-work-tree'], cwd).ok) {
|
|
17
38
|
fail('Not a git repository.');
|
|
18
39
|
return null;
|
|
19
40
|
}
|
|
20
|
-
const status = git(['status', '--porcelain']);
|
|
41
|
+
const status = git(['status', '--porcelain'], cwd);
|
|
21
42
|
if (!status.out) { ok('Nothing to commit — the working tree is clean.'); return null; }
|
|
22
43
|
|
|
23
|
-
// What the commit would contain, shown before anything happens
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
if (n > 0) out(c.dim(` ${glyph('…', '...')} and ${n} more files`));
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
// The model writes the message from the actual diff — with a firm brevity
|
|
33
|
-
// leash, because left alone every model writes essays.
|
|
34
|
-
let subject = '';
|
|
35
|
-
try {
|
|
36
|
-
const diff = git(['diff', 'HEAD']).out.slice(0, 12000);
|
|
37
|
-
const side = await runAgent({
|
|
38
|
-
client: ctx.client, cwd: ctx.flags.cwd,
|
|
39
|
-
permissions: createPermissions({ mode: 'plan' }),
|
|
40
|
-
messages: [{ role: 'user', content: `Write a git commit subject for this diff. Rules: imperative mood, no prefix, max 72 chars, say WHAT changed and WHY. Answer with the subject line ONLY — no quotes, no body, no markdown.\n\n${diff}` }],
|
|
41
|
-
});
|
|
42
|
-
const text = (side?.messages || []).filter((m) => m.role === 'assistant').pop();
|
|
43
|
-
const content = Array.isArray(text?.content) ? text.content.filter((b) => b.type === 'text').map((b) => b.text).join(' ') : (text?.content || '');
|
|
44
|
-
subject = content.trim().split('\n')[0].replace(/^["'`]|["'`]$/g, '').slice(0, 72);
|
|
45
|
-
} catch { /* fall through to the manual path */ }
|
|
44
|
+
// What the commit would contain, shown before anything happens — including files git
|
|
45
|
+
// does not track yet, which `git add -A` below will add.
|
|
46
|
+
const lines = status.out.split('\n');
|
|
47
|
+
out(c.dim(` ${lines.length} file${lines.length === 1 ? '' : 's'} changed:`));
|
|
48
|
+
for (const line of lines.slice(0, 20)) out(` ${c.dim(line)}`);
|
|
49
|
+
if (lines.length > 20) out(c.dim(` ${glyph('…', '...')} and ${lines.length - 20} more`));
|
|
46
50
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
51
|
+
let subject = args.join(' ').trim();
|
|
52
|
+
if (!subject) {
|
|
53
|
+
// The model writes the message from the actual diff — with a firm brevity leash,
|
|
54
|
+
// because left alone every model writes essays.
|
|
55
|
+
const diff = git(['diff', 'HEAD'], cwd).out.slice(0, 12000) || status.out;
|
|
56
|
+
let text = '';
|
|
57
|
+
const messages = [{ role: 'user', content: `Write a git commit subject for this change. Rules: imperative mood, no prefix, max 72 chars, say WHAT changed and WHY. Answer with the subject line ONLY — no quotes, no body, no markdown.\n\n${diff}` }];
|
|
58
|
+
try {
|
|
59
|
+
if (ctx.runSide) await ctx.runSide({ messages, mode: 'plan', onText: (t) => { text += t; } });
|
|
60
|
+
else await runAgent({ client: ctx.client, model: ctx.model, cwd, permissions: createPermissions({ mode: 'plan' }), messages, silent: true, onText: (t) => { text += t; } });
|
|
61
|
+
} catch (e) {
|
|
62
|
+
warn(`Could not write a subject (${e.message}). Give one yourself: /commit <subject>`);
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
subject = text.trim().split('\n')[0].replace(/^["'`]|["'`]$/g, '').slice(0, 72);
|
|
66
|
+
if (!subject) { warn('No subject came back. Give one yourself: /commit <subject>'); return null; }
|
|
57
67
|
}
|
|
58
68
|
|
|
59
|
-
|
|
69
|
+
out(` ${c.dim('Subject:')} ${c.bold(subject)}`);
|
|
70
|
+
const answer = await choose(ctx, `Commit ${lines.length} file${lines.length === 1 ? '' : 's'} as "${subject}"?`, [
|
|
71
|
+
['commit', 'Commit'],
|
|
72
|
+
['push', 'Commit and push'],
|
|
73
|
+
['no', 'Cancel'],
|
|
74
|
+
]);
|
|
75
|
+
if (answer !== 'commit' && answer !== 'push') { warn('Commit cancelled. To use your own subject: /commit <subject>'); return null; }
|
|
76
|
+
|
|
77
|
+
const add = git(['add', '-A'], cwd);
|
|
60
78
|
if (!add.ok) { fail(`Could not stage: ${add.err}`); return null; }
|
|
61
|
-
const done = git(['commit', '-m',
|
|
79
|
+
const done = git(['commit', '-m', subject], cwd);
|
|
62
80
|
if (!done.ok) { fail(`Commit failed: ${done.err || done.out}`); return null; }
|
|
63
|
-
ok(`Committed: ${
|
|
81
|
+
ok(`Committed: ${subject}`);
|
|
64
82
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
const pushed = git(['push', 'origin', branch]);
|
|
83
|
+
if (answer === 'push') {
|
|
84
|
+
const branch = git(['branch', '--show-current'], cwd).out || 'main';
|
|
85
|
+
const pushed = git(['push', 'origin', branch], cwd);
|
|
69
86
|
if (pushed.ok) ok(`Pushed to origin/${branch}.`);
|
|
70
87
|
else fail(`Push failed: ${pushed.err || pushed.out}`);
|
|
71
88
|
}
|
package/src/commands/doctor.js
CHANGED
|
@@ -108,7 +108,7 @@ export default async function doctor(argv = [], { json = false } = {}) {
|
|
|
108
108
|
// ── The account ───────────────────────────────────────────────────────────────
|
|
109
109
|
if (key && reachable) {
|
|
110
110
|
try {
|
|
111
|
-
const res = await reach(`${baseUrl}/api/auth/me`, { headers: {
|
|
111
|
+
const res = await reach(`${baseUrl}/api/auth/me`, { headers: { authorization: `Bearer ${key}` } });
|
|
112
112
|
const body = await res.json().catch(() => ({}));
|
|
113
113
|
const u = body.user || body.data || {};
|
|
114
114
|
if (res.ok && (u.email || u.id)) {
|
package/src/commands/explain.js
CHANGED
|
@@ -24,10 +24,10 @@ export default async function explain(args, ctx) {
|
|
|
24
24
|
: `Explain the concept "${topic}" grounded in this project where it applies — read files to find real usages rather than answering abstractly. Keep it under 250 words.`;
|
|
25
25
|
|
|
26
26
|
const sideMessages = [{ role: 'user', content: brief }];
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
27
|
+
const extraSystem = `You are the Jaitus explainer. Read-only: never edit files or run commands. Show file:line references for every claim about the code. Prefer short paragraphs over walls of text.`;
|
|
28
|
+
// In the app: the transcript, Esc stops it, the session's own rules (see runSide).
|
|
29
|
+
const side = ctx.runSide
|
|
30
|
+
? await ctx.runSide({ messages: sideMessages, mode: 'plan', extraSystem })
|
|
31
|
+
: await runAgent({ client: ctx.client, messages: sideMessages, model: ctx.model, cwd: ctx.flags.cwd, permissions: createPermissions({ mode: 'plan' }), extraSystem });
|
|
32
32
|
return null;
|
|
33
33
|
}
|
package/src/commands/logout.js
CHANGED
|
@@ -12,6 +12,8 @@ import { ok, info, warn } from '../ui.js';
|
|
|
12
12
|
export default async function logout() {
|
|
13
13
|
const { hadSession, server } = await signOut();
|
|
14
14
|
if (!hadSession) {
|
|
15
|
+
// JAITUS_TOKEN signs every request by itself, so "not signed in" was untrue with it.
|
|
16
|
+
if (process.env.JAITUS_TOKEN) { info('This terminal signs in with JAITUS_TOKEN. Unset that variable to sign out; there is no saved sign-in to remove.'); return 0; }
|
|
15
17
|
info('You were not signed in.');
|
|
16
18
|
return 0;
|
|
17
19
|
}
|
package/src/commands/research.js
CHANGED
|
@@ -48,12 +48,11 @@ export default async function research(args, ctx) {
|
|
|
48
48
|
].join('\n');
|
|
49
49
|
|
|
50
50
|
const sideMessages = [{ role: 'user', content: brief }];
|
|
51
|
-
const sidePermissions = createPermissions({ mode: 'plan' }); // read-only: web tools only
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
});
|
|
51
|
+
const sidePermissions = createPermissions({ mode: 'plan', allow: ['web_fetch'] }); // read-only: web tools only
|
|
52
|
+
const extraSystem = `You are the Jaitus research agent. You find things out on the web and report back with sources. You never edit files or run commands — web_search and web_fetch only. Be precise about what a source actually says; never invent a URL.`;
|
|
53
|
+
const side = ctx.runSide
|
|
54
|
+
? await ctx.runSide({ messages: sideMessages, mode: 'plan', extraSystem, allow: ['web_fetch'] })
|
|
55
|
+
: await runAgent({ client: ctx.client, messages: sideMessages, model: ctx.model, cwd: ctx.flags.cwd, permissions: sidePermissions, extraSystem });
|
|
57
56
|
|
|
58
57
|
ok(`Research complete (${side.tools || 0} tool calls, ${((side.input || 0) + (side.output || 0)).toLocaleString()} tokens).`);
|
|
59
58
|
return null;
|
package/src/commands/review.js
CHANGED
|
@@ -64,17 +64,14 @@ export async function runReview(mode, args, ctx) {
|
|
|
64
64
|
role: 'user',
|
|
65
65
|
content: `${brief.task(target)}\n\nDiff${target ? ` (${target})` : ''}:\n${diff.out.slice(0, 30000)}${diff.out.length > 30000 ? `\n${glyph('…', '...')}(truncated)` : ''}`,
|
|
66
66
|
}];
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
? `${brief.system}\n\nAfter reporting, APPLY the fixes you recommended (minimal diffs, same style as the file). Then summarize what changed.`
|
|
76
|
-
: brief.system,
|
|
77
|
-
});
|
|
67
|
+
// Reviewing reads; --fix writes — so the fix pass inherits the session's own
|
|
68
|
+
// permission mode AND its allow/deny rules instead of smuggling in fresh ones.
|
|
69
|
+
const runMode = fix ? (ctx.permissions?.mode || 'default') : 'plan';
|
|
70
|
+
const extraSystem = fix
|
|
71
|
+
? `${brief.system}\n\nAfter reporting, APPLY the fixes you recommended (minimal diffs, same style as the file). Then summarize what changed.`
|
|
72
|
+
: brief.system;
|
|
73
|
+
if (ctx.runSide) await ctx.runSide({ messages, mode: runMode, extraSystem });
|
|
74
|
+
else await runAgent({ client: ctx.client, messages, model: ctx.model, cwd: ctx.flags.cwd, permissions: createPermissions({ mode: runMode }), extraSystem });
|
|
78
75
|
if (!fix) warn(`Run with --fix to let the agent apply the findings (permissions still ask first).`);
|
|
79
76
|
return null;
|
|
80
77
|
}
|
package/src/commands/test.js
CHANGED
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
// only after permission, and re-runs until green or you stop it.
|
|
7
7
|
//
|
|
8
8
|
// Usage: /test [anything forwarded to the runner]
|
|
9
|
-
import { spawnSync } from 'node:child_process';
|
|
10
9
|
import fs from 'node:fs';
|
|
11
10
|
import path from 'node:path';
|
|
12
11
|
import { ok, warn, fail, out, c } from '../ui.js';
|
|
@@ -33,18 +32,25 @@ export default async function test(args, ctx) {
|
|
|
33
32
|
const runner = detectRunner(ctx.flags.cwd);
|
|
34
33
|
if (!runner) { warn('No test runner found (package.json scripts, pytest, go, cargo).'); return null; }
|
|
35
34
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
35
|
+
const command = [runner.cmd, ...runner.args, ...forward].join(' ');
|
|
36
|
+
out(c.dim(` Running: ${command} (Esc stops it)`));
|
|
37
|
+
// Through the same runner as the agent's commands: a shell (on Windows `npm` is
|
|
38
|
+
// npm.cmd, and spawnSync without one failed every time — "Tests failed (exit null)"),
|
|
39
|
+
// the whole process tree killed on Esc or at the limit, output capped — and it no
|
|
40
|
+
// longer freezes the interface while the suite runs.
|
|
41
|
+
const { byName } = await import('../agent/tools.js');
|
|
42
|
+
const run = (signal) => byName.run_command.run({ command, timeout_ms: 600_000 }, { cwd: ctx.flags.cwd, signal });
|
|
43
|
+
const output = String(ctx.abortable ? await ctx.abortable(run) : await run(null));
|
|
44
|
+
const status = Number((/exit code: (\d+)/.exec(output) || [])[1]);
|
|
45
|
+
const body = output.replace(/\n*exit code: .*$/s, '');
|
|
41
46
|
|
|
42
47
|
// Show the tail — enough to see failures, not the whole log.
|
|
43
|
-
const lines =
|
|
48
|
+
const lines = body.split('\n');
|
|
44
49
|
for (const line of lines.slice(-40)) out(` ${line}`);
|
|
45
50
|
|
|
46
|
-
if (
|
|
47
|
-
|
|
51
|
+
if (/\[stopped by the user\]/.test(output)) { warn('Test run stopped.'); return null; }
|
|
52
|
+
if (status === 0) { ok('All tests passed.'); return null; }
|
|
53
|
+
fail(`Tests failed (exit ${Number.isFinite(status) ? status : 'none'}).`);
|
|
48
54
|
|
|
49
55
|
if (!wantsFix) {
|
|
50
56
|
warn('Run /test --fix to let the agent fix the failures.');
|
|
@@ -59,10 +65,8 @@ export default async function test(args, ctx) {
|
|
|
59
65
|
role: 'user',
|
|
60
66
|
content: `These tests are failing in ${ctx.flags.cwd}. Diagnose and FIX them: read the failing code, make the minimal correct changes, then run \`${runner.label}\` again. Do not weaken assertions to make tests pass unless the test itself is provably wrong.\n\nFailure output (tail):\n${output.slice(-12000)}`,
|
|
61
67
|
}];
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
extraSystem: `You are fixing failing tests. Minimal, correct changes. Never delete a failing test to go green; fix the code (or fix the test only if the test is wrong).`,
|
|
66
|
-
});
|
|
68
|
+
const extraSystem = `You are fixing failing tests. Minimal, correct changes. Never delete a failing test to go green; fix the code (or fix the test only if the test is wrong).`;
|
|
69
|
+
if (ctx.runSide) await ctx.runSide({ messages: fixMessages, mode: ctx.permissions?.mode || 'default', extraSystem });
|
|
70
|
+
else await runAgent({ client: ctx.client, messages: fixMessages, model: ctx.model, cwd: ctx.flags.cwd, permissions: createPermissions({ mode: ctx.permissions?.mode || 'default' }), extraSystem });
|
|
67
71
|
return null;
|
|
68
72
|
}
|
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/config.js
CHANGED
|
@@ -75,8 +75,19 @@ export function loadSettings() {
|
|
|
75
75
|
return readJson(SETTINGS, {});
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
// Whoever is showing settings live (the app view) is told when they change. /scroll-speed,
|
|
79
|
+
// /animations, /config mouse and copyOnSelect used to say "saved" and do nothing until a
|
|
80
|
+
// restart, because the app had read them once at start.
|
|
81
|
+
const settingsListeners = new Set();
|
|
82
|
+
export function onSettingsChange(fn) {
|
|
83
|
+
settingsListeners.add(fn);
|
|
84
|
+
return () => settingsListeners.delete(fn);
|
|
85
|
+
}
|
|
86
|
+
|
|
78
87
|
export function saveSettings(settings) {
|
|
79
|
-
|
|
88
|
+
// 0600: it can hold search API keys (braveSearchKey, tavilyKey, serperKey).
|
|
89
|
+
writeJson(SETTINGS, settings, 0o600);
|
|
90
|
+
for (const fn of settingsListeners) { try { fn(settings); } catch { /* a listener never breaks a save */ } }
|
|
80
91
|
}
|
|
81
92
|
|
|
82
93
|
/**
|
package/src/errors.js
CHANGED
|
@@ -17,6 +17,10 @@ const CATALOG = {
|
|
|
17
17
|
plan_limit_reached: { title: 'Monthly allowance used', hint: 'It resets at the start of your next period, or upgrade on the website. /usage shows where you are.' },
|
|
18
18
|
rate_limited: { title: 'Slow down', hint: 'Wait a moment, then retry.' },
|
|
19
19
|
context_too_long: { title: 'Conversation too long', hint: 'Run /compact, or start a new session.' },
|
|
20
|
+
request_too_large: { title: 'Request too large', hint: 'Usually images: remove some, or run /compact to drop old ones. Images over 3.5 MB are shrunk automatically.' },
|
|
21
|
+
daily_limit_reached: { title: 'Daily limit reached', hint: 'Your plan\'s daily allowance resets tomorrow. /usage shows where you are.' },
|
|
22
|
+
token_expired: { title: 'Signed out', hint: 'Type /login to sign in again (or run `jaitus login`).' },
|
|
23
|
+
refresh_race: { title: 'Sign-in was being renewed', hint: 'Try again.' },
|
|
20
24
|
content_blocked: { title: 'Request refused', hint: 'Rephrase the request and try again.' },
|
|
21
25
|
bad_request: { title: 'Request not accepted', hint: 'Try again; if it repeats, run /debug and contact support.' },
|
|
22
26
|
model_unavailable: { title: 'Model unavailable', hint: 'Run /model to pick another.' },
|