jaitus 0.3.14 → 0.3.16

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jaitus",
3
- "version": "0.3.14",
3
+ "version": "0.3.16",
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/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",
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 && node tests/compaction.js && node tests/copy-clean.js && node tests/parallel-tools.js",
20
20
  "preview": "node scripts/tui-preview.js",
21
21
  "prepublishOnly": "npm test"
22
22
  },
package/src/agent/loop.js CHANGED
@@ -262,6 +262,12 @@ export function repairToolPairs(messages) {
262
262
  export const TRIM_AT = 40_000; // estimated tokens of conversation
263
263
  export const KEEP_RECENT = 6; // latest tool results always kept whole
264
264
  export const TRIM_AT_LEAST = 12_000; // estimated tokens a clearing must free
265
+ // Past DEEP_AT only the newest KEEP_RECENT_DEEP results stay whole. Production, 7 days to
266
+ // 2 Oct: requests carrying 150k+ tokens were a quarter of all requests and 62% of all
267
+ // credits — every step of a long task re-sent the outputs of its last six tools whole,
268
+ // up to 60,000 characters each.
269
+ export const DEEP_AT = 100_000;
270
+ export const KEEP_RECENT_DEEP = 3;
265
271
  const TRIM_MIN_CHARS = 1_500; // results shorter than this are never cleared
266
272
  const CLEARED = '[Output cleared';
267
273
 
@@ -281,8 +287,10 @@ export function conversationTokens(messages) {
281
287
  * Clear old tool outputs in place when the conversation is large. Returns how many
282
288
  * estimated tokens were freed (0 when nothing was worth clearing).
283
289
  */
284
- export function clearOldToolResults(messages, { at = TRIM_AT, keep = KEEP_RECENT, atLeast = TRIM_AT_LEAST } = {}) {
285
- if (conversationTokens(messages) < at) return 0;
290
+ export function clearOldToolResults(messages, { at = TRIM_AT, keep, atLeast = TRIM_AT_LEAST } = {}) {
291
+ const size = conversationTokens(messages);
292
+ if (size < at) return 0;
293
+ if (keep === undefined) keep = size >= DEEP_AT ? KEEP_RECENT_DEEP : KEEP_RECENT;
286
294
  const names = new Map();
287
295
  for (const m of messages) {
288
296
  if (m?.role === 'assistant' && Array.isArray(m.content)) for (const b of m.content) if (b?.type === 'tool_use') names.set(b.id, b.name);
@@ -306,6 +314,9 @@ export function clearOldToolResults(messages, { at = TRIM_AT, keep = KEEP_RECENT
306
314
  return freed;
307
315
  }
308
316
 
317
+ // Tools that change nothing and ask nothing: several in one batch run at the same time.
318
+ const PARALLEL_SAFE = new Set(['read_file', 'glob', 'list_files', 'search', 'web_search', 'web_fetch', 'background_list', 'background_read', 'load_skill']);
319
+
309
320
  export const IMAGE_BUDGET = 6_000_000;
310
321
  export function withinImageBudget(messages, budget = IMAGE_BUDGET) {
311
322
  let used = 0;
@@ -495,7 +506,7 @@ export async function runAgent({
495
506
  client, messages, model, cwd, permissions, signal, tui = null,
496
507
  extraSystem = '', maxIterations = MAX_ITERATIONS, silent = false,
497
508
  onText = null, onEvent = null, systemOverride = null, journal = null,
498
- subagents = true, onContext = null, trimContext = true,
509
+ subagents = true, onContext = null, trimContext = true, beforeStep = null,
499
510
  }) {
500
511
  // Loop detection. A call is identified by its tool, its input, and how many files had
501
512
  // been changed when it was made — so edit → test → edit → test is progress (the count
@@ -544,6 +555,11 @@ export async function runAgent({
544
555
  const freed = clearOldToolResults(messages);
545
556
  if (freed && tui?.notice) tui.notice(`Cleared old tool output (~${Math.round(freed / 1000)}k tokens) to keep each request small.`, 'info');
546
557
  }
558
+ // The caller may compact the history between steps (index.js: when the context is
559
+ // nearly full). Never before the first step — the caller already checked then.
560
+ if (beforeStep && iteration > 0 && !signal?.aborted) {
561
+ try { await beforeStep(messages); } catch { /* a failed compaction is reported by the caller; the step still runs */ }
562
+ }
547
563
  let streamed = false;
548
564
  const spin = tui ? { stop: () => {}, update: () => {} } : (silent ? { stop: () => {}, update: () => {} } : spinner(glyph('Working…', 'Working...')));
549
565
  if (tui) tui.setStatus(`${c.brand(glyph('•', '*'))} ${c.brand(glyph('Working…', 'Working...'))}`);
@@ -728,6 +744,26 @@ export async function runAgent({
728
744
  return { ...totals, stopReason: 'stuck' };
729
745
  }
730
746
 
747
+ // Read-only calls in one batch run AT THE SAME TIME. The model is told to batch
748
+ // independent reads, and then they ran one after another anyway: five file reads or
749
+ // three searches cost five or three round trips of wall time. Only tools that change
750
+ // nothing and need no permission start early; their rows, results and order are
751
+ // exactly as before — the loop below just finds the work already done. Anything
752
+ // that writes, runs a command or asks the user still runs one by one, in order.
753
+ const early = new Map();
754
+ const parallel = calls.filter((call) => {
755
+ const t = toolMap[call.name];
756
+ return t && PARALLEL_SAFE.has(call.name) && !t.needsPermission && call.input?.__parse_error === undefined
757
+ && !planBlocked.has(call.id) && permissions?.verdict?.(call.name, call.input, cwd) !== 'deny';
758
+ });
759
+ if (parallel.length > 1 && !signal?.aborted) {
760
+ for (const call of parallel) {
761
+ const started = Promise.resolve().then(() => toolMap[call.name].run(call.input, { cwd, signal }));
762
+ started.catch(() => { /* reported when the loop reaches the call */ });
763
+ early.set(call.id, started);
764
+ }
765
+ }
766
+
731
767
  // Every call gets a result, whatever happened.
732
768
  const results = [];
733
769
  for (const call of calls) {
@@ -811,7 +847,7 @@ export async function runAgent({
811
847
  } else if (journal && call.name === 'run_command') {
812
848
  journal.snapshotTracked(cwd);
813
849
  }
814
- const output = await tool.run(call.input, { cwd, signal });
850
+ const output = await (early.get(call.id) ?? tool.run(call.input, { cwd, signal }));
815
851
  spin2.stop(); stopElapsed();
816
852
  const took = ((Date.now() - toolStarted) / 1000).toFixed(1);
817
853
  // opencode tool icons: $ shell, ← edits, → reads, ✱ search,
package/src/api.js CHANGED
@@ -1,174 +1,177 @@
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
- }
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
+ import { machineHeader } from './machine.js';
11
+
12
+ export class NotSignedIn extends Error {}
13
+
14
+ // fetch() has no timeout of its own, so a request the gateway never answered used to
15
+ // pin the CLI on "Working..." forever. Every call below carries a deadline: slow
16
+ // becomes a `timeout` JaitusError (retryable by the agent loop, shown with a request
17
+ // id) instead of eternity. The caller's signal (Esc) still wins: it aborts through the
18
+ // same controller, and only a genuine user abort reads as one.
19
+ //
20
+ // A turn is bounded by SILENCE, not by its length. Until the reply starts, the gateway
21
+ // may be failing over between provider keys (each gets a 20-45 s budget), so the first
22
+ // byte gets a few minutes. Once it starts, the gateway writes a heartbeat at least every
23
+ // 10 s for as long as the provider is working, so 90 s without a single byte means the
24
+ // connection is dead — while a long, healthy answer streams for as long as it needs.
25
+ // (A fixed 10-minute ceiling on the whole request used to cut long answers off and
26
+ // start them again from nothing.)
27
+ // (Overridable only so the tests can drive a stall in under a second.)
28
+ const FIRST_BYTE_TIMEOUT_MS = Number(process.env.JAITUS_FIRST_BYTE_MS) || 3 * 60 * 1000;
29
+ const STREAM_IDLE_TIMEOUT_MS = Number(process.env.JAITUS_STREAM_IDLE_MS) || 90 * 1000;
30
+ const API_TIMEOUT_MS = 30 * 1000; // dashboard calls are small JSON
31
+
32
+ // Combine an optional caller signal with a restartable deadline into one signal.
33
+ // Manual combine (not AbortSignal.any): .any needs Node 20.3+, this works everywhere.
34
+ function withTimeout(signal, ms) {
35
+ const ctrl = new AbortController();
36
+ let timedOut = false;
37
+ let timer = null;
38
+ const arm = (after) => {
39
+ clearTimeout(timer);
40
+ timer = setTimeout(() => {
41
+ timedOut = true;
42
+ try { ctrl.abort(new DOMException('Request timed out', 'TimeoutError')); }
43
+ catch { try { ctrl.abort(); } catch { /* already gone */ } }
44
+ }, after);
45
+ if (timer.unref) timer.unref();
46
+ };
47
+ arm(ms);
48
+ const onAbort = () => {
49
+ clearTimeout(timer);
50
+ try { ctrl.abort(signal.reason); } catch { try { ctrl.abort(); } catch { /* already gone */ } }
51
+ };
52
+ if (signal) {
53
+ if (signal.aborted) onAbort();
54
+ else signal.addEventListener('abort', onAbort, { once: true });
55
+ }
56
+ return {
57
+ signal: ctrl.signal,
58
+ // True only when OUR timer fired. A user abort also lands here as an AbortError,
59
+ // but signal.aborted is set then — never mistake Esc for a timeout.
60
+ timedOut: () => timedOut && !signal?.aborted,
61
+ /** Start the deadline again from now (with a new length). */
62
+ restart: (after) => { if (!ctrl.signal.aborted) arm(after); },
63
+ cleanup: () => { clearTimeout(timer); signal?.removeEventListener?.('abort', onAbort); },
64
+ };
65
+ }
66
+
67
+ /**
68
+ * The response with its body watched for silence: every chunk restarts the idle
69
+ * deadline, and the end of the body (or the reader letting go of it) stops the timer
70
+ * and drops the caller's abort listener, so nothing outlives the request.
71
+ */
72
+ function watchedResponse(res, t, idleMs) {
73
+ if (!res.body) { t.cleanup(); return res; }
74
+ t.restart(idleMs);
75
+ const body = res.body.pipeThrough(new TransformStream({
76
+ transform(chunk, controller) { t.restart(idleMs); controller.enqueue(chunk); },
77
+ flush() { t.cleanup(); },
78
+ cancel() { t.cleanup(); },
79
+ }));
80
+ return new Response(body, { status: res.status, statusText: res.statusText, headers: res.headers });
81
+ }
82
+
83
+ function timeoutError() {
84
+ return new JaitusError({ code: 'timeout', status: 504, message: 'Jaitus took too long to respond. Try again.' });
85
+ }
86
+
87
+ // The context window the gateway last advertised, or null before it has been asked.
88
+ let contextWindowSeen = null;
89
+ export const advertisedContextWindow = () => contextWindowSeen;
90
+
91
+ export function createClient() {
92
+ const { baseUrl } = endpoints();
93
+ if (!signedIn()) throw new NotSignedIn('Not signed in. Run `jaitus login` first.');
94
+
95
+ // x-jaitus-features: what this build understands, so the gateway can use newer error
96
+ // codes (a used-up usage window) without an older CLI reading them as "went wrong".
97
+ const base = { 'Content-Type': 'application/json', 'user-agent': `jaitus-cli/${version()}`, 'x-jaitus-features': 'usage-window' };
98
+ // x-jaitus-device: this computer's hashed identifiers (machine.js), which the free
99
+ // tier is limited by. Built once per process.
100
+ const headers = (token) => ({ ...base, 'x-jaitus-device': machineHeader(), authorization: `Bearer ${token}` });
101
+
102
+ return {
103
+ baseUrl,
104
+
105
+ /** POST /v1/messages. Returns the raw Response so the caller can stream it. */
106
+ async messages(body, { signal } = {}) {
107
+ const t = withTimeout(signal, FIRST_BYTE_TIMEOUT_MS);
108
+ let res;
109
+ try {
110
+ res = await withAuth((token) => fetch(`${baseUrl}/v1/messages`, {
111
+ method: 'POST',
112
+ headers: headers(token),
113
+ body: JSON.stringify(body),
114
+ signal: t.signal,
115
+ }));
116
+ } catch (cause) {
117
+ t.cleanup();
118
+ if (cause?.name === 'JaitusError') throw cause;
119
+ if (t.timedOut()) throw timeoutError();
120
+ if (cause?.name === 'AbortError') throw cause;
121
+ throw fromNetwork(cause);
122
+ }
123
+ // A mid-body stall (headers fast, then nothing) aborts the body; the reader sees
124
+ // an AbortError while the caller's signal is NOT aborted, and turns it into a
125
+ // timeout — keeping whatever already arrived (agent/loop.js).
126
+ return watchedResponse(res, t, STREAM_IDLE_TIMEOUT_MS);
127
+ },
128
+
129
+ /** A dashboard API call, returning parsed JSON. */
130
+ async api(pathname, init = {}) {
131
+ const t = withTimeout(init.signal, API_TIMEOUT_MS);
132
+ let res;
133
+ try {
134
+ res = await withAuth((token) => fetch(`${baseUrl}/api${pathname}`, {
135
+ ...init,
136
+ headers: { ...headers(token), ...(init.headers || {}) },
137
+ signal: t.signal,
138
+ }));
139
+ const body = await res.json().catch(() => ({}));
140
+ return { ok: res.ok, status: res.status, body };
141
+ } catch (cause) {
142
+ if (cause?.name === 'JaitusError') throw cause;
143
+ if (t.timedOut()) throw timeoutError();
144
+ if (cause?.name === 'AbortError') throw cause;
145
+ throw fromNetwork(cause);
146
+ } finally {
147
+ t.cleanup();
148
+ }
149
+ },
150
+
151
+ /** Model ids the gateway advertises. */
152
+ async models() {
153
+ const t = withTimeout(undefined, API_TIMEOUT_MS);
154
+ let res;
155
+ try {
156
+ res = await withAuth((token) => fetch(`${baseUrl}/v1/models`, { headers: headers(token), signal: t.signal }));
157
+ } catch (cause) {
158
+ t.cleanup();
159
+ if (cause?.name === 'JaitusError') throw cause;
160
+ if (t.timedOut()) throw timeoutError();
161
+ if (cause?.name === 'AbortError') throw cause;
162
+ throw fromNetwork(cause);
163
+ }
164
+ const body = await res.json().catch(() => ({}));
165
+ t.cleanup();
166
+ if (!res.ok) throw fromGatewayBody(res.status, body, { requestId: res.headers.get('x-request-id') || '' });
167
+ const models = (body?.data || []).map((m) => m?.id).filter(Boolean);
168
+ // How much conversation fits, as the gateway computed it for this account.
169
+ const ctx = (body?.data || []).map((m) => Number(m?.context_window)).find((n) => n > 0);
170
+ if (ctx) contextWindowSeen = ctx;
171
+ // An empty list is a service fault, not something the user can fix — and naming
172
+ // the gateway's model map to them would be naming our internals.
173
+ if (!models.length) throw new JaitusError({ code: 'unavailable', status: 502 });
174
+ return models;
175
+ },
176
+ };
177
+ }
@@ -121,10 +121,15 @@ function run(cmd, args, { capture = false } = {}) {
121
121
  export function installGlobal(v, { onTick = () => {}, onStart = () => {} } = {}) {
122
122
  return new Promise((resolve) => {
123
123
  const args = ['install', '-g', `jaitus@${v}`, '--no-audit', '--no-fund', '--loglevel=error'];
124
- const opts = { stdio: ['ignore', 'ignore', 'pipe'], detached: true, windowsHide: true };
124
+ // Windows: `detached` gives the child a console of its OWN — the update opened a
125
+ // second terminal window. Instead npm runs in this window through `start /b`, which
126
+ // makes it ignore Ctrl+C (cmd's documented behaviour), so it still cannot be cut off
127
+ // halfway; delayed expansion hands npm's exit code back through `start /wait`.
128
+ // Elsewhere a detached process group does the same without any window.
125
129
  const child = process.platform === 'win32'
126
- ? spawn(['npm', ...args].join(' '), { ...opts, shell: true })
127
- : spawn('npm', args, opts);
130
+ ? spawn('cmd.exe', ['/d', '/v:on', '/s', '/c', `"start "" /b /wait npm ${args.join(' ')} & exit !errorlevel!"`],
131
+ { stdio: ['ignore', 'ignore', 'pipe'], windowsHide: true, windowsVerbatimArguments: true })
132
+ : spawn('npm', args, { stdio: ['ignore', 'ignore', 'pipe'], detached: true });
128
133
  if (child.pid) onStart(child.pid);
129
134
  let stderr = '';
130
135
  child.stderr?.on('data', (d) => { stderr = (stderr + d).slice(-4000); });