jaitus 0.3.12 → 0.3.14
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/bin/jaitus.js +86 -73
- package/package.json +2 -2
- package/src/agent/loop.js +998 -980
- package/src/agent/tools.js +11 -5
- package/src/api.js +55 -21
- package/src/commands/update.js +75 -49
- package/src/errors.js +120 -106
- package/src/index.js +3152 -3142
- package/src/rewind.js +149 -148
- package/src/serviceNotice.js +151 -0
- package/src/sessions.js +8 -0
- package/src/tui/clipboard.js +172 -161
- package/src/tui-ink.js +2841 -2605
- package/src/tui.js +2560 -2559
package/src/agent/tools.js
CHANGED
|
@@ -746,11 +746,17 @@ export const TOOLS = [
|
|
|
746
746
|
async run(input) {
|
|
747
747
|
if (input.clear) { todoState.splice(0, todoState.length); return 'Task list cleared.'; }
|
|
748
748
|
if (!Array.isArray(input.todos)) throw new ToolError('Give a todos array (or clear: true).');
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
749
|
+
// Models trained on other agents name the step `content` (or title, text…) and the
|
|
750
|
+
// status "done" or "in-progress". Only `task` used to count, so a perfectly good
|
|
751
|
+
// plan was refused as "no usable items" and the list never appeared.
|
|
752
|
+
const STATUS = { pending: 'pending', todo: 'pending', not_started: 'pending', in_progress: 'in_progress', inprogress: 'in_progress', active: 'in_progress', doing: 'in_progress', started: 'in_progress', completed: 'completed', complete: 'completed', done: 'completed', finished: 'completed' };
|
|
753
|
+
const cleaned = input.todos.map((t) => {
|
|
754
|
+
const item = typeof t === 'string' ? { task: t } : (t || {});
|
|
755
|
+
const text = [item.task, item.content, item.title, item.text, item.description, item.step, item.name].find((v) => typeof v === 'string' && v.trim());
|
|
756
|
+
const key = String(item.status || '').trim().toLowerCase().replace(/[\s-]+/g, '_');
|
|
757
|
+
return { task: String(text || '').trim(), status: STATUS[key] || (item.done === true || item.completed === true ? 'completed' : 'pending') };
|
|
758
|
+
}).filter((t) => t.task);
|
|
759
|
+
if (!cleaned.length) throw new ToolError('The list has no usable items. Each item is { "task": "one step", "status": "pending" | "in_progress" | "completed" }.');
|
|
754
760
|
todoState.splice(0, todoState.length, ...cleaned);
|
|
755
761
|
return `Task list updated (${cleaned.filter((t) => t.status === 'completed').length}/${cleaned.length} done).`;
|
|
756
762
|
},
|
package/src/api.js
CHANGED
|
@@ -4,32 +4,46 @@
|
|
|
4
4
|
// a JaitusError here, at the edge, so no caller ever has an HTTP body in its hands to
|
|
5
5
|
// print — see errors.js for why that matters.
|
|
6
6
|
|
|
7
|
-
import { endpoints } from './config.js';
|
|
7
|
+
import { endpoints, version } from './config.js';
|
|
8
8
|
import { signedIn, withAuth } from './session.js';
|
|
9
9
|
import { fromGatewayBody, fromNetwork, JaitusError } from './errors.js';
|
|
10
10
|
|
|
11
11
|
export class NotSignedIn extends Error {}
|
|
12
12
|
|
|
13
|
-
// fetch() has no timeout of its own,
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
// instead of eternity. The caller's signal (Esc) still wins: it aborts through the
|
|
13
|
+
// fetch() has no timeout of its own, so a request the gateway never answered used to
|
|
14
|
+
// pin the CLI on "Working..." forever. Every call below carries a deadline: slow
|
|
15
|
+
// becomes a `timeout` JaitusError (retryable by the agent loop, shown with a request
|
|
16
|
+
// id) instead of eternity. The caller's signal (Esc) still wins: it aborts through the
|
|
18
17
|
// same controller, and only a genuine user abort reads as one.
|
|
19
|
-
|
|
18
|
+
//
|
|
19
|
+
// A turn is bounded by SILENCE, not by its length. Until the reply starts, the gateway
|
|
20
|
+
// may be failing over between provider keys (each gets a 20-45 s budget), so the first
|
|
21
|
+
// byte gets a few minutes. Once it starts, the gateway writes a heartbeat at least every
|
|
22
|
+
// 10 s for as long as the provider is working, so 90 s without a single byte means the
|
|
23
|
+
// connection is dead — while a long, healthy answer streams for as long as it needs.
|
|
24
|
+
// (A fixed 10-minute ceiling on the whole request used to cut long answers off and
|
|
25
|
+
// start them again from nothing.)
|
|
26
|
+
// (Overridable only so the tests can drive a stall in under a second.)
|
|
27
|
+
const FIRST_BYTE_TIMEOUT_MS = Number(process.env.JAITUS_FIRST_BYTE_MS) || 3 * 60 * 1000;
|
|
28
|
+
const STREAM_IDLE_TIMEOUT_MS = Number(process.env.JAITUS_STREAM_IDLE_MS) || 90 * 1000;
|
|
20
29
|
const API_TIMEOUT_MS = 30 * 1000; // dashboard calls are small JSON
|
|
21
30
|
|
|
22
|
-
// Combine an optional caller signal with a
|
|
31
|
+
// Combine an optional caller signal with a restartable deadline into one signal.
|
|
23
32
|
// Manual combine (not AbortSignal.any): .any needs Node 20.3+, this works everywhere.
|
|
24
33
|
function withTimeout(signal, ms) {
|
|
25
34
|
const ctrl = new AbortController();
|
|
26
35
|
let timedOut = false;
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
36
|
+
let timer = null;
|
|
37
|
+
const arm = (after) => {
|
|
38
|
+
clearTimeout(timer);
|
|
39
|
+
timer = setTimeout(() => {
|
|
40
|
+
timedOut = true;
|
|
41
|
+
try { ctrl.abort(new DOMException('Request timed out', 'TimeoutError')); }
|
|
42
|
+
catch { try { ctrl.abort(); } catch { /* already gone */ } }
|
|
43
|
+
}, after);
|
|
44
|
+
if (timer.unref) timer.unref();
|
|
45
|
+
};
|
|
46
|
+
arm(ms);
|
|
33
47
|
const onAbort = () => {
|
|
34
48
|
clearTimeout(timer);
|
|
35
49
|
try { ctrl.abort(signal.reason); } catch { try { ctrl.abort(); } catch { /* already gone */ } }
|
|
@@ -43,10 +57,28 @@ function withTimeout(signal, ms) {
|
|
|
43
57
|
// True only when OUR timer fired. A user abort also lands here as an AbortError,
|
|
44
58
|
// but signal.aborted is set then — never mistake Esc for a timeout.
|
|
45
59
|
timedOut: () => timedOut && !signal?.aborted,
|
|
60
|
+
/** Start the deadline again from now (with a new length). */
|
|
61
|
+
restart: (after) => { if (!ctrl.signal.aborted) arm(after); },
|
|
46
62
|
cleanup: () => { clearTimeout(timer); signal?.removeEventListener?.('abort', onAbort); },
|
|
47
63
|
};
|
|
48
64
|
}
|
|
49
65
|
|
|
66
|
+
/**
|
|
67
|
+
* The response with its body watched for silence: every chunk restarts the idle
|
|
68
|
+
* deadline, and the end of the body (or the reader letting go of it) stops the timer
|
|
69
|
+
* and drops the caller's abort listener, so nothing outlives the request.
|
|
70
|
+
*/
|
|
71
|
+
function watchedResponse(res, t, idleMs) {
|
|
72
|
+
if (!res.body) { t.cleanup(); return res; }
|
|
73
|
+
t.restart(idleMs);
|
|
74
|
+
const body = res.body.pipeThrough(new TransformStream({
|
|
75
|
+
transform(chunk, controller) { t.restart(idleMs); controller.enqueue(chunk); },
|
|
76
|
+
flush() { t.cleanup(); },
|
|
77
|
+
cancel() { t.cleanup(); },
|
|
78
|
+
}));
|
|
79
|
+
return new Response(body, { status: res.status, statusText: res.statusText, headers: res.headers });
|
|
80
|
+
}
|
|
81
|
+
|
|
50
82
|
function timeoutError() {
|
|
51
83
|
return new JaitusError({ code: 'timeout', status: 504, message: 'Jaitus took too long to respond. Try again.' });
|
|
52
84
|
}
|
|
@@ -61,7 +93,7 @@ export function createClient() {
|
|
|
61
93
|
|
|
62
94
|
// x-jaitus-features: what this build understands, so the gateway can use newer error
|
|
63
95
|
// codes (a used-up usage window) without an older CLI reading them as "went wrong".
|
|
64
|
-
const base = { 'Content-Type': 'application/json', 'user-agent':
|
|
96
|
+
const base = { 'Content-Type': 'application/json', 'user-agent': `jaitus-cli/${version()}`, 'x-jaitus-features': 'usage-window' };
|
|
65
97
|
const headers = (token) => ({ ...base, authorization: `Bearer ${token}` });
|
|
66
98
|
|
|
67
99
|
return {
|
|
@@ -69,24 +101,26 @@ export function createClient() {
|
|
|
69
101
|
|
|
70
102
|
/** POST /v1/messages. Returns the raw Response so the caller can stream it. */
|
|
71
103
|
async messages(body, { signal } = {}) {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
// that. Aborting a finished request is harmless; the timer never holds the
|
|
75
|
-
// process open (unref). 10 minutes: streamed agentic turns legitimately run long.
|
|
76
|
-
const t = withTimeout(signal, MESSAGES_TIMEOUT_MS);
|
|
104
|
+
const t = withTimeout(signal, FIRST_BYTE_TIMEOUT_MS);
|
|
105
|
+
let res;
|
|
77
106
|
try {
|
|
78
|
-
|
|
107
|
+
res = await withAuth((token) => fetch(`${baseUrl}/v1/messages`, {
|
|
79
108
|
method: 'POST',
|
|
80
109
|
headers: headers(token),
|
|
81
110
|
body: JSON.stringify(body),
|
|
82
111
|
signal: t.signal,
|
|
83
112
|
}));
|
|
84
113
|
} catch (cause) {
|
|
114
|
+
t.cleanup();
|
|
85
115
|
if (cause?.name === 'JaitusError') throw cause;
|
|
86
116
|
if (t.timedOut()) throw timeoutError();
|
|
87
117
|
if (cause?.name === 'AbortError') throw cause;
|
|
88
118
|
throw fromNetwork(cause);
|
|
89
119
|
}
|
|
120
|
+
// A mid-body stall (headers fast, then nothing) aborts the body; the reader sees
|
|
121
|
+
// an AbortError while the caller's signal is NOT aborted, and turns it into a
|
|
122
|
+
// timeout — keeping whatever already arrived (agent/loop.js).
|
|
123
|
+
return watchedResponse(res, t, STREAM_IDLE_TIMEOUT_MS);
|
|
90
124
|
},
|
|
91
125
|
|
|
92
126
|
/** A dashboard API call, returning parsed JSON. */
|
package/src/commands/update.js
CHANGED
|
@@ -14,7 +14,7 @@ import fs from 'node:fs';
|
|
|
14
14
|
import path from 'node:path';
|
|
15
15
|
import { spawn } from 'node:child_process';
|
|
16
16
|
import { fileURLToPath } from 'node:url';
|
|
17
|
-
import { c, out, ok, info, warn, fail, present
|
|
17
|
+
import { c, out, ok, info, warn, fail, present } from '../ui.js';
|
|
18
18
|
import { version, HOME } from '../config.js';
|
|
19
19
|
import { RUNNING_FILE } from '../pendingUpdate.js';
|
|
20
20
|
|
|
@@ -89,7 +89,7 @@ export async function updateNotice(opts = {}) {
|
|
|
89
89
|
const current = opts.current || version();
|
|
90
90
|
const latest = await cachedLatest(opts).catch(() => null);
|
|
91
91
|
if (!latest || !isNewer(latest, current)) return null;
|
|
92
|
-
return `Jaitus v${latest} is out — you have v${current}. Quit and
|
|
92
|
+
return `Jaitus v${latest} is out — you have v${current}. Quit and start jaitus again: it updates itself as it starts.`;
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
function run(cmd, args, { capture = false } = {}) {
|
|
@@ -147,7 +147,14 @@ export async function installMethod({ here = fileURLToPath(import.meta.url), npm
|
|
|
147
147
|
const norm = (p) => path.resolve(p).toLowerCase();
|
|
148
148
|
if (/[\\/]_npx[\\/]/.test(real)) return 'npx';
|
|
149
149
|
const root = npmRoot ?? (await run('npm', ['root', '-g'], { capture: true })).stdout.trim();
|
|
150
|
-
if (root
|
|
150
|
+
if (!root) return 'other';
|
|
151
|
+
// Both sides through their links: with nvm (and similar) npm's global folder is a link
|
|
152
|
+
// to the active Node's folder, so this file's real path never started with the
|
|
153
|
+
// reported one, and a perfectly normal npm install was skipped as "not npm".
|
|
154
|
+
const realRoot = (() => { try { return fs.realpathSync(root); } catch { return root; } })();
|
|
155
|
+
for (const r of new Set([root, realRoot])) {
|
|
156
|
+
if (norm(real).startsWith(norm(path.join(r, 'jaitus')) + path.sep.toLowerCase())) return 'npm-global';
|
|
157
|
+
}
|
|
151
158
|
return 'other';
|
|
152
159
|
}
|
|
153
160
|
|
|
@@ -173,56 +180,70 @@ export function isNewer(a, b) {
|
|
|
173
180
|
}
|
|
174
181
|
|
|
175
182
|
/**
|
|
176
|
-
* Update on launch, then reopen
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
* started again on the new version.
|
|
183
|
+
* Update on launch, then reopen. Every time `jaitus` starts in a terminal, the registry
|
|
184
|
+
* is asked — always, not from a cache — and when a newer version is out it is installed,
|
|
185
|
+
* with progress on screen, and the same command is started again on the new version.
|
|
180
186
|
*
|
|
181
|
-
* It
|
|
182
|
-
*
|
|
183
|
-
* day,
|
|
187
|
+
* It used to skip far too often: the registry got 1.5 s (a slower answer meant this
|
|
188
|
+
* launch went ahead on the old version), one failed install switched updating off for a
|
|
189
|
+
* day, a copy whose npm folder is reached through a link (nvm and similar) was taken for
|
|
190
|
+
* "not an npm install" and skipped without a word, and a setting could turn it off. Now:
|
|
191
|
+
* - the check waits up to `waitMs` (10 s), saying "Checking for updates…" while it does;
|
|
192
|
+
* - a failed install says why and how to fix it, and is tried again on the next launch;
|
|
193
|
+
* - a copy that cannot update itself (npx, a linked or vendored copy) says so, every time;
|
|
194
|
+
* - only CI, scripts/pipes and JAITUS_NO_AUTOUPDATE (for tests and automation) skip it.
|
|
184
195
|
*
|
|
185
|
-
*
|
|
196
|
+
* It never blocks the session on a failure: the user is told, and the app starts.
|
|
186
197
|
* Returns the child's exit code when it relaunched, or null to carry on in this process.
|
|
187
198
|
*/
|
|
188
|
-
export async function autoUpdate({ argv = [],
|
|
199
|
+
export async function autoUpdate({ argv = [], env = process.env, current = version(), log = out,
|
|
189
200
|
check = () => checkRegistry(), method = () => installMethod(), install = null,
|
|
190
|
-
relaunch = defaultRelaunch,
|
|
191
|
-
|
|
192
|
-
runningFile = RUNNING_FILE } = {}) {
|
|
201
|
+
relaunch = defaultRelaunch, cacheFile = CHECK_FILE, waitMs = 10_000, write = (s) => process.stdout.write(s),
|
|
202
|
+
runningFile = RUNNING_FILE, showAfterMs = 300 } = {}) {
|
|
193
203
|
if (env.JAITUS_UPDATED_FROM) return null; // just relaunched: never loop
|
|
194
|
-
if (!updateCheckEnabled(env) || env.JAITUS_NO_AUTOUPDATE
|
|
204
|
+
if (!updateCheckEnabled(env) || env.JAITUS_NO_AUTOUPDATE) return null;
|
|
195
205
|
if (!process.stdout.isTTY || !process.stdin.isTTY) return null; // scripts and pipes get a stable version
|
|
196
206
|
const first = argv.find((a) => !a.startsWith('-'));
|
|
197
207
|
if (['update', 'completion', 'help', 'logout'].includes(first) || argv.some((a) => ['--version', '-v', '--help', '-h', '--print', '-p'].includes(a))) return null;
|
|
198
|
-
try {
|
|
199
|
-
const failed = JSON.parse(fs.readFileSync(failFile, 'utf8'));
|
|
200
|
-
if (now - Number(failed.at) < DAY_MS) return null;
|
|
201
|
-
} catch { /* no recent failure */ }
|
|
202
208
|
|
|
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
209
|
const remember = (latest) => {
|
|
207
210
|
try { fs.mkdirSync(path.dirname(cacheFile), { recursive: true }); fs.writeFileSync(cacheFile, JSON.stringify({ checkedAt: Date.now(), latest: latest || null })); } catch { /* read-only home */ }
|
|
208
211
|
};
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
212
|
+
// Ask the registry. A quick answer shows nothing; a slow one says what is happening.
|
|
213
|
+
let shown = false;
|
|
214
|
+
const showing = setTimeout(() => { shown = true; write(` ${c.dim('Checking for updates…')}`); }, showAfterMs);
|
|
215
|
+
// An answer that arrives after the wait is still recorded, for the next launch.
|
|
216
|
+
const asked = Promise.resolve().then(check).then((r) => { if (r?.version) remember(r.version); return r; }, () => ({ version: null, status: 'unreachable' }));
|
|
217
|
+
const answer = await Promise.race([
|
|
218
|
+
asked,
|
|
219
|
+
new Promise((resolve) => setTimeout(() => resolve({ version: null, status: 'timeout' }), waitMs).unref()),
|
|
220
|
+
]);
|
|
221
|
+
clearTimeout(showing);
|
|
222
|
+
if (shown) write('\r\x1b[2K');
|
|
223
|
+
let latest = answer?.version || null;
|
|
224
|
+
// (an answer is recorded by `asked` itself, on time or late)
|
|
214
225
|
if (!latest) {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
226
|
+
// No answer this time. A newer version seen earlier is still worth installing.
|
|
227
|
+
try {
|
|
228
|
+
const cached = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
|
|
229
|
+
if (cached?.latest && isNewer(cached.latest, current)) latest = cached.latest;
|
|
230
|
+
} catch { /* no earlier answer */ }
|
|
231
|
+
if (!latest) {
|
|
232
|
+
if (answer?.status !== 'unpublished') log(` ${c.dim(`Could not reach npm to check for updates — starting v${current}.`)}`);
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (!isNewer(latest, current)) return null;
|
|
237
|
+
|
|
238
|
+
const how = await method();
|
|
239
|
+
if (how !== 'npm-global') {
|
|
240
|
+
log(` ${c.yellow('!')} ${c.bold(`Jaitus v${latest}`)} ${c.dim(`is out — you have v${current}, and this copy cannot update itself.`)}`);
|
|
241
|
+
log(` ${c.dim(how === 'npx' ? 'Start it with' : 'Update it with')} ${c.bold(how === 'npx' ? 'npx jaitus@latest' : 'npm install -g jaitus@latest')}`);
|
|
242
|
+
return null;
|
|
221
243
|
}
|
|
222
|
-
if (!latest || !isNewer(latest, current)) return null;
|
|
223
|
-
if ((await method()) !== 'npm-global') return null; // the in-app notice covers these
|
|
224
244
|
|
|
225
245
|
const head = ` ${c.brand('Jaitus')} ${c.dim(`v${current} →`)} ${c.bold(`v${latest}`)}`;
|
|
246
|
+
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
226
247
|
write(`${head} ${c.dim('— updating…')}`);
|
|
227
248
|
// Ctrl+C now leaves npm to finish on its own (it is detached) — say so rather than
|
|
228
249
|
// leaving someone to wonder whether they broke the install. Runs before the bin's own
|
|
@@ -232,7 +253,7 @@ export async function autoUpdate({ argv = [], settings = {}, env = process.env,
|
|
|
232
253
|
};
|
|
233
254
|
process.prependListener('SIGINT', onInterrupt);
|
|
234
255
|
const doInstall = install || ((v) => installGlobal(v, {
|
|
235
|
-
onTick: (s) => write(`\r${head} ${c.dim(
|
|
256
|
+
onTick: (s) => write(`\r\x1b[2K${head} ${c.brand(frames[s % frames.length])} ${c.dim(`updating… ${s}s — this usually takes under a minute`)}`),
|
|
236
257
|
// Left behind only if this process is gone before npm is — the next launch waits on it.
|
|
237
258
|
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
259
|
}));
|
|
@@ -243,14 +264,15 @@ export async function autoUpdate({ argv = [], settings = {}, env = process.env,
|
|
|
243
264
|
}
|
|
244
265
|
write('\n');
|
|
245
266
|
if (result.code !== 0) {
|
|
246
|
-
try { fs.mkdirSync(path.dirname(failFile), { recursive: true }); fs.writeFileSync(failFile, JSON.stringify({ at: now, version: latest })); } catch { /* best effort */ }
|
|
247
267
|
const needsSudo = /EACCES|permission denied/i.test(result.stderr || '');
|
|
248
|
-
|
|
268
|
+
const busy = /EBUSY|EPERM|resource busy|operation not permitted/i.test(result.stderr || '');
|
|
269
|
+
log(` ${c.yellow('!')} ${c.dim('The update to')} v${latest} ${c.dim('did not install — starting')} v${current}${c.dim('. It is tried again next time you start jaitus.')}`);
|
|
270
|
+
if (busy) log(` ${c.dim('Another jaitus window may be holding its files: close every jaitus window, then start it again.')}`);
|
|
271
|
+
log(` ${c.dim('To install it by hand:')} ${c.bold(process.platform !== 'win32' && needsSudo ? 'sudo npm install -g jaitus@latest' : 'npm install -g jaitus@latest')}`);
|
|
249
272
|
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(`
|
|
273
|
+
if (reason) log(` ${c.dim(reason.slice(0, 200))}`);
|
|
251
274
|
return null;
|
|
252
275
|
}
|
|
253
|
-
remember(latest);
|
|
254
276
|
log(` ${c.accent('✓')} ${c.dim(`Updated to v${latest} — reopening`)}`);
|
|
255
277
|
return relaunch({ ...env, JAITUS_UPDATED_FROM: current });
|
|
256
278
|
}
|
|
@@ -303,16 +325,20 @@ export default async function update(argv = [], { json = false, interactive = tr
|
|
|
303
325
|
manual();
|
|
304
326
|
return 0;
|
|
305
327
|
}
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
|
|
328
|
+
// Asking for the update IS the yes: it installs straight away, with the same progress
|
|
329
|
+
// line as an update on launch.
|
|
330
|
+
const tty = process.stdout.isTTY;
|
|
331
|
+
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
332
|
+
const line = ` ${c.dim('installing')} v${latest}`;
|
|
333
|
+
if (tty) process.stdout.write(`${line}…`); else out(`${line} (npm install -g jaitus@${latest})`);
|
|
334
|
+
const { code, stderr } = await installGlobal(latest, {
|
|
335
|
+
onTick: (s) => { if (tty) process.stdout.write(`\r\x1b[2K${line} ${c.brand(frames[s % frames.length])} ${c.dim(`${s}s — this usually takes under a minute`)}`); },
|
|
336
|
+
});
|
|
337
|
+
if (tty) process.stdout.write('\n');
|
|
314
338
|
if (code !== 0) {
|
|
315
339
|
fail(`npm could not install it (exit ${code}). Try it yourself — on macOS or Linux it may need sudo, or a Node version manager:`);
|
|
340
|
+
const reason = (stderr || '').split('\n').map((l) => l.trim()).filter(Boolean).find((l) => /ERR|EACCES|EPERM|EBUSY|ENOTEMPTY/i.test(l));
|
|
341
|
+
if (reason) info(reason.slice(0, 200));
|
|
316
342
|
manual();
|
|
317
343
|
return 1;
|
|
318
344
|
}
|
package/src/errors.js
CHANGED
|
@@ -1,106 +1,120 @@
|
|
|
1
|
-
// ─── What the CLI says when something fails ─────────────────────────────────────
|
|
2
|
-
//
|
|
3
|
-
// The gateway answers every failure with a `jaitus_*` code from one catalog. This file
|
|
4
|
-
// is the CLI's half of that contract: a code maps to a title and a hint written here,
|
|
5
|
-
// and anything WITHOUT a known code — an HTML page from a proxy, a body in some other
|
|
6
|
-
// shape, a code this build has never heard of — becomes "something went wrong in
|
|
7
|
-
// Jaitus" plus the request id. The CLI never prints an error body it does not
|
|
8
|
-
// recognise, because the only text that is safe to show is text we wrote.
|
|
9
|
-
|
|
10
|
-
const CATALOG = {
|
|
11
|
-
auth_required: { title: 'Not signed in', hint: 'Type /login to sign in (or run `jaitus login`).' },
|
|
12
|
-
session_expired: { title: 'Signed out', hint: 'Type /login to sign in again (or run `jaitus login`).' },
|
|
13
|
-
account_suspended: { title: 'Account suspended', hint: 'Contact support if you think this is a mistake.' },
|
|
14
|
-
email_unverified: { title: 'Email not verified', hint: 'Open the verification link in your inbox, then retry.' },
|
|
15
|
-
free_limit_reached: { title: 'Free prompts used up', hint: 'Subscribe on the website, then retry this turn.' },
|
|
16
|
-
free_request_too_large: { title: 'Too large for the free plan', hint: 'Run /compact or start a new session, or subscribe.' },
|
|
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
|
-
rate_limited: { title: 'Slow down', hint: 'Wait a moment, then retry.' },
|
|
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: 'Usage limit reached', hint: 'It refills automatically. /usage shows where you are.' },
|
|
22
|
-
window_limit_reached: { title: 'This usage window is used up', hint: 'It refills when the window ends — the rest of your month is still there. /usage shows when.' },
|
|
23
|
-
token_expired: { title: 'Signed out', hint: 'Type /login to sign in again (or run `jaitus login`).' },
|
|
24
|
-
refresh_race: { title: 'Sign-in was being renewed', hint: 'Try again.' },
|
|
25
|
-
content_blocked: { title: 'Request refused', hint: 'Rephrase the request and try again.' },
|
|
26
|
-
bad_request: { title: 'Request not accepted', hint: 'Try again; if it repeats, run /debug and contact support.' },
|
|
27
|
-
model_unavailable: { title: 'Model unavailable', hint: 'Run /model to pick another.' },
|
|
28
|
-
busy: { title: 'Jaitus is busy', hint: 'Try again in a minute.' },
|
|
29
|
-
unavailable: { title: 'Jaitus is unavailable', hint: 'Try again shortly. `jaitus doctor` checks your side.' },
|
|
30
|
-
maintenance: { title: 'Down for maintenance', hint: 'Try again shortly.' },
|
|
31
|
-
timeout: { title: 'Timed out', hint: 'Try again.' },
|
|
32
|
-
interrupted: { title: 'Response interrupted', hint: 'Try again.' },
|
|
33
|
-
internal: { title: 'Something went wrong', hint: 'Try again; if it keeps happening, contact support with the request id.' },
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
const GENERIC = 'Something went wrong in Jaitus.';
|
|
37
|
-
|
|
38
|
-
export class JaitusError extends Error {
|
|
39
|
-
constructor({ code = 'internal', message, status = 0, requestId = '', retryAfter = null } = {}) {
|
|
40
|
-
const known = Object.prototype.hasOwnProperty.call(CATALOG, code) ? code : 'internal';
|
|
41
|
-
super(message || GENERIC);
|
|
42
|
-
this.name = 'JaitusError';
|
|
43
|
-
this.code = known;
|
|
44
|
-
this.status = status;
|
|
45
|
-
this.requestId = requestId;
|
|
46
|
-
this.retryAfter = retryAfter;
|
|
47
|
-
this.title = CATALOG[known].title;
|
|
48
|
-
this.hint = CATALOG[known].hint;
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* An error body from the gateway → a JaitusError.
|
|
54
|
-
*
|
|
55
|
-
* The gateway's message is shown ONLY when the body carries a recognised `jaitus_`
|
|
56
|
-
* code, because only then is it guaranteed to be our wording (it may carry specifics,
|
|
57
|
-
* like how many free prompts there were). Otherwise the message is ours, generic.
|
|
58
|
-
*/
|
|
59
|
-
export function fromGatewayBody(status, body, { requestId = '', retryAfter = null } = {}) {
|
|
60
|
-
let parsed = body;
|
|
61
|
-
if (typeof body === 'string') {
|
|
62
|
-
try { parsed = JSON.parse(body); } catch { parsed = null; }
|
|
63
|
-
}
|
|
64
|
-
const err = parsed?.error || null;
|
|
65
|
-
const rawCode = String(err?.code || '');
|
|
66
|
-
const code = rawCode.startsWith('jaitus_') ? rawCode.slice(7) : '';
|
|
67
|
-
const known = code && Object.prototype.hasOwnProperty.call(CATALOG, code);
|
|
68
|
-
const id = String(parsed?.request_id || requestId || '');
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
retryAfter,
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
error
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
1
|
+
// ─── What the CLI says when something fails ─────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// The gateway answers every failure with a `jaitus_*` code from one catalog. This file
|
|
4
|
+
// is the CLI's half of that contract: a code maps to a title and a hint written here,
|
|
5
|
+
// and anything WITHOUT a known code — an HTML page from a proxy, a body in some other
|
|
6
|
+
// shape, a code this build has never heard of — becomes "something went wrong in
|
|
7
|
+
// Jaitus" plus the request id. The CLI never prints an error body it does not
|
|
8
|
+
// recognise, because the only text that is safe to show is text we wrote.
|
|
9
|
+
|
|
10
|
+
const CATALOG = {
|
|
11
|
+
auth_required: { title: 'Not signed in', hint: 'Type /login to sign in (or run `jaitus login`).' },
|
|
12
|
+
session_expired: { title: 'Signed out', hint: 'Type /login to sign in again (or run `jaitus login`).' },
|
|
13
|
+
account_suspended: { title: 'Account suspended', hint: 'Contact support if you think this is a mistake.' },
|
|
14
|
+
email_unverified: { title: 'Email not verified', hint: 'Open the verification link in your inbox, then retry.' },
|
|
15
|
+
free_limit_reached: { title: 'Free prompts used up', hint: 'Subscribe on the website, then retry this turn.' },
|
|
16
|
+
free_request_too_large: { title: 'Too large for the free plan', hint: 'Run /compact or start a new session, or subscribe.' },
|
|
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
|
+
rate_limited: { title: 'Slow down', hint: 'Wait a moment, then retry.' },
|
|
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: 'Usage limit reached', hint: 'It refills automatically. /usage shows where you are.' },
|
|
22
|
+
window_limit_reached: { title: 'This usage window is used up', hint: 'It refills when the window ends — the rest of your month is still there. /usage shows when.' },
|
|
23
|
+
token_expired: { title: 'Signed out', hint: 'Type /login to sign in again (or run `jaitus login`).' },
|
|
24
|
+
refresh_race: { title: 'Sign-in was being renewed', hint: 'Try again.' },
|
|
25
|
+
content_blocked: { title: 'Request refused', hint: 'Rephrase the request and try again.' },
|
|
26
|
+
bad_request: { title: 'Request not accepted', hint: 'Try again; if it repeats, run /debug and contact support.' },
|
|
27
|
+
model_unavailable: { title: 'Model unavailable', hint: 'Run /model to pick another.' },
|
|
28
|
+
busy: { title: 'Jaitus is busy', hint: 'Try again in a minute.' },
|
|
29
|
+
unavailable: { title: 'Jaitus is unavailable', hint: 'Try again shortly. `jaitus doctor` checks your side.' },
|
|
30
|
+
maintenance: { title: 'Down for maintenance', hint: 'Try again shortly.' },
|
|
31
|
+
timeout: { title: 'Timed out', hint: 'Try again.' },
|
|
32
|
+
interrupted: { title: 'Response interrupted', hint: 'Try again.' },
|
|
33
|
+
internal: { title: 'Something went wrong', hint: 'Try again; if it keeps happening, contact support with the request id.' },
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const GENERIC = 'Something went wrong in Jaitus.';
|
|
37
|
+
|
|
38
|
+
export class JaitusError extends Error {
|
|
39
|
+
constructor({ code = 'internal', message, status = 0, requestId = '', retryAfter = null } = {}) {
|
|
40
|
+
const known = Object.prototype.hasOwnProperty.call(CATALOG, code) ? code : 'internal';
|
|
41
|
+
super(message || GENERIC);
|
|
42
|
+
this.name = 'JaitusError';
|
|
43
|
+
this.code = known;
|
|
44
|
+
this.status = status;
|
|
45
|
+
this.requestId = requestId;
|
|
46
|
+
this.retryAfter = retryAfter;
|
|
47
|
+
this.title = CATALOG[known].title;
|
|
48
|
+
this.hint = CATALOG[known].hint;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* An error body from the gateway → a JaitusError.
|
|
54
|
+
*
|
|
55
|
+
* The gateway's message is shown ONLY when the body carries a recognised `jaitus_`
|
|
56
|
+
* code, because only then is it guaranteed to be our wording (it may carry specifics,
|
|
57
|
+
* like how many free prompts there were). Otherwise the message is ours, generic.
|
|
58
|
+
*/
|
|
59
|
+
export function fromGatewayBody(status, body, { requestId = '', retryAfter = null } = {}) {
|
|
60
|
+
let parsed = body;
|
|
61
|
+
if (typeof body === 'string') {
|
|
62
|
+
try { parsed = JSON.parse(body); } catch { parsed = null; }
|
|
63
|
+
}
|
|
64
|
+
const err = parsed?.error || null;
|
|
65
|
+
const rawCode = String(err?.code || '');
|
|
66
|
+
const code = rawCode.startsWith('jaitus_') ? rawCode.slice(7) : '';
|
|
67
|
+
const known = code && Object.prototype.hasOwnProperty.call(CATALOG, code);
|
|
68
|
+
const id = String(parsed?.request_id || requestId || '');
|
|
69
|
+
// No Jaitus code on a gateway-class status: the answer came from the proxy in front of
|
|
70
|
+
// the gateway (its timeout page, a 502 while a deploy restarts the server), not from
|
|
71
|
+
// the gateway. Those are "not right now" — said plainly and retried — never the
|
|
72
|
+
// dead-end "Something went wrong" they used to read as.
|
|
73
|
+
if (!known && (status === 504 || status === 524)) {
|
|
74
|
+
return new JaitusError({ code: 'timeout', status, requestId: id, retryAfter, message: 'Jaitus took too long to answer and the connection was closed on the way. Try again.' });
|
|
75
|
+
}
|
|
76
|
+
if (!known && (status === 502 || status === 503 || (status >= 520 && status <= 530))) {
|
|
77
|
+
return new JaitusError({ code: 'unavailable', status, requestId: id, retryAfter, message: 'Jaitus could not be reached just now. Try again in a moment.' });
|
|
78
|
+
}
|
|
79
|
+
return new JaitusError({
|
|
80
|
+
code: known ? code : 'internal',
|
|
81
|
+
message: known && typeof err?.message === 'string' && err.message.trim() ? err.message.trim() : GENERIC,
|
|
82
|
+
status,
|
|
83
|
+
requestId: id,
|
|
84
|
+
retryAfter,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A failed fetch Response → a JaitusError, reading the body once. */
|
|
89
|
+
export async function fromResponse(res) {
|
|
90
|
+
const text = await res.text().catch(() => '');
|
|
91
|
+
const requestId = res.headers?.get?.('x-request-id') || '';
|
|
92
|
+
const retryAfter = Number(res.headers?.get?.('retry-after')) || null;
|
|
93
|
+
const error = fromGatewayBody(res.status, text, { requestId, retryAfter });
|
|
94
|
+
// The gateway says trying again cannot help yet (e.g. no provider is switched on):
|
|
95
|
+
// report it at once instead of retrying behind "Working…".
|
|
96
|
+
if (res.headers?.get?.('x-jaitus-retry') === 'no') error.noRetry = true;
|
|
97
|
+
return error;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* fetch() threw before any response — the network, not the service. This is the one
|
|
102
|
+
* failure where the true cause is on the user's side and worth naming plainly.
|
|
103
|
+
*/
|
|
104
|
+
export function fromNetwork(cause) {
|
|
105
|
+
if (cause?.name === 'AbortError') return cause;
|
|
106
|
+
const error = new JaitusError({
|
|
107
|
+
code: 'unavailable',
|
|
108
|
+
message: 'Could not reach Jaitus. Check your internet connection.',
|
|
109
|
+
});
|
|
110
|
+
error.title = 'Cannot reach Jaitus';
|
|
111
|
+
error.hint = 'Check your connection, then run `jaitus doctor`.';
|
|
112
|
+
error.cause = cause;
|
|
113
|
+
return error;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function isJaitusError(e) {
|
|
117
|
+
return e instanceof JaitusError || e?.name === 'JaitusError';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export const ERROR_CODES = Object.freeze(Object.keys(CATALOG));
|