jaitus 0.3.13 → 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/package.json +2 -2
- package/src/agent/loop.js +9 -1
- package/src/agent/tools.js +11 -5
- package/src/api.js +174 -174
- package/src/commands/update.js +75 -49
- package/src/index.js +7 -3
- package/src/serviceNotice.js +151 -0
- package/src/sessions.js +8 -0
- package/src/tui-ink.js +258 -36
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jaitus",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.14",
|
|
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/react-build.js && node tests/stalls.js && node tests/clipboard-write.js && node tests/rewind-images.js && node tests/rules.js && node tests/run.js && node tests/commands.js && node tests/login.js && node tests/session.js && node tests/openUrl.js && node tests/ink-tui.js && node tests/tui-render.js && node tests/mouse-e2e.js && node tests/environment.js && node tests/update.js && node tests/continuation.js && node tests/steps.js && node tests/files.js && node tests/processes.js && node tests/security.js && node tests/context-budget.js && node tests/context-trim.js",
|
|
19
|
+
"test": "node tests/source-sanity.js && node tests/react-build.js && node tests/stalls.js && node tests/clipboard-write.js && node tests/rewind-images.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/service-notices.js && node tests/continuation.js && node tests/steps.js && node tests/files.js && node tests/processes.js && node tests/security.js && node tests/context-budget.js && node tests/context-trim.js",
|
|
20
20
|
"preview": "node scripts/tui-preview.js",
|
|
21
21
|
"prepublishOnly": "npm test"
|
|
22
22
|
},
|
package/src/agent/loop.js
CHANGED
|
@@ -32,6 +32,7 @@ import { c, out, write, spinner, glyph } from '../ui.js';
|
|
|
32
32
|
import { discoverMcpTools } from '../mcp.js';
|
|
33
33
|
import { unifiedDiff } from '../diff.js';
|
|
34
34
|
import { fromResponse, fromGatewayBody, JaitusError } from '../errors.js';
|
|
35
|
+
import { explainFailure } from '../serviceNotice.js';
|
|
35
36
|
|
|
36
37
|
const MAX_ITERATIONS = 1000;
|
|
37
38
|
const STUCK_REPEATS = 4;
|
|
@@ -76,7 +77,10 @@ export function retryDelay(e, attempt = 0) {
|
|
|
76
77
|
return hinted <= MAX_RETRY_WAIT_MS ? hinted : null;
|
|
77
78
|
}
|
|
78
79
|
if (!TRANSIENT_CODES.has(code) || code === 'internal') return null;
|
|
79
|
-
return Math.min(BLIP_MAX_WAIT_MS, hinted
|
|
80
|
+
if (hinted != null) return Math.min(BLIP_MAX_WAIT_MS, hinted);
|
|
81
|
+
// ±25% spread: every CLI cut off by the same restart would otherwise retry in the
|
|
82
|
+
// same instant and arrive together.
|
|
83
|
+
return Math.min(BLIP_MAX_WAIT_MS, Math.round(2000 * 2 ** attempt * (0.75 + Math.random() * 0.5)));
|
|
80
84
|
}
|
|
81
85
|
|
|
82
86
|
function sleepUnlessAborted(ms, signal) {
|
|
@@ -610,6 +614,10 @@ export async function runAgent({
|
|
|
610
614
|
else if (!silent) out(c.yellow(` ${glyph('⏹', '[x]')} Interrupted.`));
|
|
611
615
|
return { ...totals, stopReason: 'interrupted' };
|
|
612
616
|
}
|
|
617
|
+
// Out of retries: say what the status page knows (back-at time, known problem).
|
|
618
|
+
// Asked of the same service the request went to; a client with no address (a test
|
|
619
|
+
// double) asks nothing.
|
|
620
|
+
if (client?.baseUrl) await explainFailure(e, { baseUrl: client.baseUrl }).catch(() => {});
|
|
613
621
|
throw e;
|
|
614
622
|
}
|
|
615
623
|
spin.stop();
|
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
|
@@ -1,174 +1,174 @@
|
|
|
1
|
-
// ─── Talking to Jaitus ──────────────────────────────────────────────────────────
|
|
2
|
-
//
|
|
3
|
-
// The CLI is the only client of the gateway. Every failure it gets back is turned into
|
|
4
|
-
// a JaitusError here, at the edge, so no caller ever has an HTTP body in its hands to
|
|
5
|
-
// print — see errors.js for why that matters.
|
|
6
|
-
|
|
7
|
-
import { endpoints } from './config.js';
|
|
8
|
-
import { signedIn, withAuth } from './session.js';
|
|
9
|
-
import { fromGatewayBody, fromNetwork, JaitusError } from './errors.js';
|
|
10
|
-
|
|
11
|
-
export class NotSignedIn extends Error {}
|
|
12
|
-
|
|
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
|
|
17
|
-
// same controller, and only a genuine user abort reads as one.
|
|
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;
|
|
29
|
-
const API_TIMEOUT_MS = 30 * 1000; // dashboard calls are small JSON
|
|
30
|
-
|
|
31
|
-
// Combine an optional caller signal with a restartable deadline into one signal.
|
|
32
|
-
// Manual combine (not AbortSignal.any): .any needs Node 20.3+, this works everywhere.
|
|
33
|
-
function withTimeout(signal, ms) {
|
|
34
|
-
const ctrl = new AbortController();
|
|
35
|
-
let timedOut = false;
|
|
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);
|
|
47
|
-
const onAbort = () => {
|
|
48
|
-
clearTimeout(timer);
|
|
49
|
-
try { ctrl.abort(signal.reason); } catch { try { ctrl.abort(); } catch { /* already gone */ } }
|
|
50
|
-
};
|
|
51
|
-
if (signal) {
|
|
52
|
-
if (signal.aborted) onAbort();
|
|
53
|
-
else signal.addEventListener('abort', onAbort, { once: true });
|
|
54
|
-
}
|
|
55
|
-
return {
|
|
56
|
-
signal: ctrl.signal,
|
|
57
|
-
// True only when OUR timer fired. A user abort also lands here as an AbortError,
|
|
58
|
-
// but signal.aborted is set then — never mistake Esc for a timeout.
|
|
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); },
|
|
62
|
-
cleanup: () => { clearTimeout(timer); signal?.removeEventListener?.('abort', onAbort); },
|
|
63
|
-
};
|
|
64
|
-
}
|
|
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
|
-
|
|
82
|
-
function timeoutError() {
|
|
83
|
-
return new JaitusError({ code: 'timeout', status: 504, message: 'Jaitus took too long to respond. Try again.' });
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// The context window the gateway last advertised, or null before it has been asked.
|
|
87
|
-
let contextWindowSeen = null;
|
|
88
|
-
export const advertisedContextWindow = () => contextWindowSeen;
|
|
89
|
-
|
|
90
|
-
export function createClient() {
|
|
91
|
-
const { baseUrl } = endpoints();
|
|
92
|
-
if (!signedIn()) throw new NotSignedIn('Not signed in. Run `jaitus login` first.');
|
|
93
|
-
|
|
94
|
-
// x-jaitus-features: what this build understands, so the gateway can use newer error
|
|
95
|
-
// codes (a used-up usage window) without an older CLI reading them as "went wrong".
|
|
96
|
-
const base = { 'Content-Type': 'application/json', 'user-agent':
|
|
97
|
-
const headers = (token) => ({ ...base, authorization: `Bearer ${token}` });
|
|
98
|
-
|
|
99
|
-
return {
|
|
100
|
-
baseUrl,
|
|
101
|
-
|
|
102
|
-
/** POST /v1/messages. Returns the raw Response so the caller can stream it. */
|
|
103
|
-
async messages(body, { signal } = {}) {
|
|
104
|
-
const t = withTimeout(signal, FIRST_BYTE_TIMEOUT_MS);
|
|
105
|
-
let res;
|
|
106
|
-
try {
|
|
107
|
-
res = await withAuth((token) => fetch(`${baseUrl}/v1/messages`, {
|
|
108
|
-
method: 'POST',
|
|
109
|
-
headers: headers(token),
|
|
110
|
-
body: JSON.stringify(body),
|
|
111
|
-
signal: t.signal,
|
|
112
|
-
}));
|
|
113
|
-
} catch (cause) {
|
|
114
|
-
t.cleanup();
|
|
115
|
-
if (cause?.name === 'JaitusError') throw cause;
|
|
116
|
-
if (t.timedOut()) throw timeoutError();
|
|
117
|
-
if (cause?.name === 'AbortError') throw cause;
|
|
118
|
-
throw fromNetwork(cause);
|
|
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);
|
|
124
|
-
},
|
|
125
|
-
|
|
126
|
-
/** A dashboard API call, returning parsed JSON. */
|
|
127
|
-
async api(pathname, init = {}) {
|
|
128
|
-
const t = withTimeout(init.signal, API_TIMEOUT_MS);
|
|
129
|
-
let res;
|
|
130
|
-
try {
|
|
131
|
-
res = await withAuth((token) => fetch(`${baseUrl}/api${pathname}`, {
|
|
132
|
-
...init,
|
|
133
|
-
headers: { ...headers(token), ...(init.headers || {}) },
|
|
134
|
-
signal: t.signal,
|
|
135
|
-
}));
|
|
136
|
-
const body = await res.json().catch(() => ({}));
|
|
137
|
-
return { ok: res.ok, status: res.status, body };
|
|
138
|
-
} catch (cause) {
|
|
139
|
-
if (cause?.name === 'JaitusError') throw cause;
|
|
140
|
-
if (t.timedOut()) throw timeoutError();
|
|
141
|
-
if (cause?.name === 'AbortError') throw cause;
|
|
142
|
-
throw fromNetwork(cause);
|
|
143
|
-
} finally {
|
|
144
|
-
t.cleanup();
|
|
145
|
-
}
|
|
146
|
-
},
|
|
147
|
-
|
|
148
|
-
/** Model ids the gateway advertises. */
|
|
149
|
-
async models() {
|
|
150
|
-
const t = withTimeout(undefined, API_TIMEOUT_MS);
|
|
151
|
-
let res;
|
|
152
|
-
try {
|
|
153
|
-
res = await withAuth((token) => fetch(`${baseUrl}/v1/models`, { headers: headers(token), signal: t.signal }));
|
|
154
|
-
} catch (cause) {
|
|
155
|
-
t.cleanup();
|
|
156
|
-
if (cause?.name === 'JaitusError') throw cause;
|
|
157
|
-
if (t.timedOut()) throw timeoutError();
|
|
158
|
-
if (cause?.name === 'AbortError') throw cause;
|
|
159
|
-
throw fromNetwork(cause);
|
|
160
|
-
}
|
|
161
|
-
const body = await res.json().catch(() => ({}));
|
|
162
|
-
t.cleanup();
|
|
163
|
-
if (!res.ok) throw fromGatewayBody(res.status, body, { requestId: res.headers.get('x-request-id') || '' });
|
|
164
|
-
const models = (body?.data || []).map((m) => m?.id).filter(Boolean);
|
|
165
|
-
// How much conversation fits, as the gateway computed it for this account.
|
|
166
|
-
const ctx = (body?.data || []).map((m) => Number(m?.context_window)).find((n) => n > 0);
|
|
167
|
-
if (ctx) contextWindowSeen = ctx;
|
|
168
|
-
// An empty list is a service fault, not something the user can fix — and naming
|
|
169
|
-
// the gateway's model map to them would be naming our internals.
|
|
170
|
-
if (!models.length) throw new JaitusError({ code: 'unavailable', status: 502 });
|
|
171
|
-
return models;
|
|
172
|
-
},
|
|
173
|
-
};
|
|
174
|
-
}
|
|
1
|
+
// ─── Talking to Jaitus ──────────────────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// The CLI is the only client of the gateway. Every failure it gets back is turned into
|
|
4
|
+
// a JaitusError here, at the edge, so no caller ever has an HTTP body in its hands to
|
|
5
|
+
// print — see errors.js for why that matters.
|
|
6
|
+
|
|
7
|
+
import { endpoints, version } from './config.js';
|
|
8
|
+
import { signedIn, withAuth } from './session.js';
|
|
9
|
+
import { fromGatewayBody, fromNetwork, JaitusError } from './errors.js';
|
|
10
|
+
|
|
11
|
+
export class NotSignedIn extends Error {}
|
|
12
|
+
|
|
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
|
|
17
|
+
// same controller, and only a genuine user abort reads as one.
|
|
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;
|
|
29
|
+
const API_TIMEOUT_MS = 30 * 1000; // dashboard calls are small JSON
|
|
30
|
+
|
|
31
|
+
// Combine an optional caller signal with a restartable deadline into one signal.
|
|
32
|
+
// Manual combine (not AbortSignal.any): .any needs Node 20.3+, this works everywhere.
|
|
33
|
+
function withTimeout(signal, ms) {
|
|
34
|
+
const ctrl = new AbortController();
|
|
35
|
+
let timedOut = false;
|
|
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);
|
|
47
|
+
const onAbort = () => {
|
|
48
|
+
clearTimeout(timer);
|
|
49
|
+
try { ctrl.abort(signal.reason); } catch { try { ctrl.abort(); } catch { /* already gone */ } }
|
|
50
|
+
};
|
|
51
|
+
if (signal) {
|
|
52
|
+
if (signal.aborted) onAbort();
|
|
53
|
+
else signal.addEventListener('abort', onAbort, { once: true });
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
signal: ctrl.signal,
|
|
57
|
+
// True only when OUR timer fired. A user abort also lands here as an AbortError,
|
|
58
|
+
// but signal.aborted is set then — never mistake Esc for a timeout.
|
|
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); },
|
|
62
|
+
cleanup: () => { clearTimeout(timer); signal?.removeEventListener?.('abort', onAbort); },
|
|
63
|
+
};
|
|
64
|
+
}
|
|
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
|
+
|
|
82
|
+
function timeoutError() {
|
|
83
|
+
return new JaitusError({ code: 'timeout', status: 504, message: 'Jaitus took too long to respond. Try again.' });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// The context window the gateway last advertised, or null before it has been asked.
|
|
87
|
+
let contextWindowSeen = null;
|
|
88
|
+
export const advertisedContextWindow = () => contextWindowSeen;
|
|
89
|
+
|
|
90
|
+
export function createClient() {
|
|
91
|
+
const { baseUrl } = endpoints();
|
|
92
|
+
if (!signedIn()) throw new NotSignedIn('Not signed in. Run `jaitus login` first.');
|
|
93
|
+
|
|
94
|
+
// x-jaitus-features: what this build understands, so the gateway can use newer error
|
|
95
|
+
// codes (a used-up usage window) without an older CLI reading them as "went wrong".
|
|
96
|
+
const base = { 'Content-Type': 'application/json', 'user-agent': `jaitus-cli/${version()}`, 'x-jaitus-features': 'usage-window' };
|
|
97
|
+
const headers = (token) => ({ ...base, authorization: `Bearer ${token}` });
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
baseUrl,
|
|
101
|
+
|
|
102
|
+
/** POST /v1/messages. Returns the raw Response so the caller can stream it. */
|
|
103
|
+
async messages(body, { signal } = {}) {
|
|
104
|
+
const t = withTimeout(signal, FIRST_BYTE_TIMEOUT_MS);
|
|
105
|
+
let res;
|
|
106
|
+
try {
|
|
107
|
+
res = await withAuth((token) => fetch(`${baseUrl}/v1/messages`, {
|
|
108
|
+
method: 'POST',
|
|
109
|
+
headers: headers(token),
|
|
110
|
+
body: JSON.stringify(body),
|
|
111
|
+
signal: t.signal,
|
|
112
|
+
}));
|
|
113
|
+
} catch (cause) {
|
|
114
|
+
t.cleanup();
|
|
115
|
+
if (cause?.name === 'JaitusError') throw cause;
|
|
116
|
+
if (t.timedOut()) throw timeoutError();
|
|
117
|
+
if (cause?.name === 'AbortError') throw cause;
|
|
118
|
+
throw fromNetwork(cause);
|
|
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);
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
/** A dashboard API call, returning parsed JSON. */
|
|
127
|
+
async api(pathname, init = {}) {
|
|
128
|
+
const t = withTimeout(init.signal, API_TIMEOUT_MS);
|
|
129
|
+
let res;
|
|
130
|
+
try {
|
|
131
|
+
res = await withAuth((token) => fetch(`${baseUrl}/api${pathname}`, {
|
|
132
|
+
...init,
|
|
133
|
+
headers: { ...headers(token), ...(init.headers || {}) },
|
|
134
|
+
signal: t.signal,
|
|
135
|
+
}));
|
|
136
|
+
const body = await res.json().catch(() => ({}));
|
|
137
|
+
return { ok: res.ok, status: res.status, body };
|
|
138
|
+
} catch (cause) {
|
|
139
|
+
if (cause?.name === 'JaitusError') throw cause;
|
|
140
|
+
if (t.timedOut()) throw timeoutError();
|
|
141
|
+
if (cause?.name === 'AbortError') throw cause;
|
|
142
|
+
throw fromNetwork(cause);
|
|
143
|
+
} finally {
|
|
144
|
+
t.cleanup();
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
/** Model ids the gateway advertises. */
|
|
149
|
+
async models() {
|
|
150
|
+
const t = withTimeout(undefined, API_TIMEOUT_MS);
|
|
151
|
+
let res;
|
|
152
|
+
try {
|
|
153
|
+
res = await withAuth((token) => fetch(`${baseUrl}/v1/models`, { headers: headers(token), signal: t.signal }));
|
|
154
|
+
} catch (cause) {
|
|
155
|
+
t.cleanup();
|
|
156
|
+
if (cause?.name === 'JaitusError') throw cause;
|
|
157
|
+
if (t.timedOut()) throw timeoutError();
|
|
158
|
+
if (cause?.name === 'AbortError') throw cause;
|
|
159
|
+
throw fromNetwork(cause);
|
|
160
|
+
}
|
|
161
|
+
const body = await res.json().catch(() => ({}));
|
|
162
|
+
t.cleanup();
|
|
163
|
+
if (!res.ok) throw fromGatewayBody(res.status, body, { requestId: res.headers.get('x-request-id') || '' });
|
|
164
|
+
const models = (body?.data || []).map((m) => m?.id).filter(Boolean);
|
|
165
|
+
// How much conversation fits, as the gateway computed it for this account.
|
|
166
|
+
const ctx = (body?.data || []).map((m) => Number(m?.context_window)).find((n) => n > 0);
|
|
167
|
+
if (ctx) contextWindowSeen = ctx;
|
|
168
|
+
// An empty list is a service fault, not something the user can fix — and naming
|
|
169
|
+
// the gateway's model map to them would be naming our internals.
|
|
170
|
+
if (!models.length) throw new JaitusError({ code: 'unavailable', status: 502 });
|
|
171
|
+
return models;
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|
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
|
}
|