jaitus 0.3.8 → 0.3.10

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.8",
3
+ "version": "0.3.10",
4
4
  "description": "Jaitus — a coding agent in your terminal.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/agent/loop.js CHANGED
@@ -31,7 +31,7 @@ import { loadContext, loadIgnore, CONTEXT_FILE } from '../project.js';
31
31
  import { c, out, write, spinner, glyph } from '../ui.js';
32
32
  import { discoverMcpTools } from '../mcp.js';
33
33
  import { unifiedDiff } from '../diff.js';
34
- import { fromResponse, fromGatewayBody } from '../errors.js';
34
+ import { fromResponse, fromGatewayBody, JaitusError } from '../errors.js';
35
35
 
36
36
  const MAX_ITERATIONS = 1000;
37
37
  const STUCK_REPEATS = 4;
@@ -425,7 +425,12 @@ async function streamTurn({ client, messages, model, system, tools, onText, onTo
425
425
  if (streamError) break;
426
426
  }
427
427
  } catch (e) {
428
- if (signal?.aborted || e?.name === 'AbortError') throw e;
428
+ // Esc is the only abort that reads as an interruption. Any OTHER AbortError —
429
+ // the request timeout in api.js firing mid-body, a dropped socket — is a `timeout`
430
+ // JaitusError so the agent loop backs off and retries it (TRANSIENT_CODES) instead
431
+ // of reporting "Interrupted." for something the user never stopped.
432
+ if (signal?.aborted) throw e;
433
+ if (e?.name === 'AbortError') throw new JaitusError({ code: 'timeout', status: 504, message: 'Jaitus took too long to respond. Try again.' });
429
434
  // The connection dropped mid-answer. What arrived is kept and continued from; with
430
435
  // nothing received yet, the caller retries the request instead.
431
436
  if (!blocks.size) throw e;
package/src/api.js CHANGED
@@ -10,6 +10,47 @@ 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, and the gateway's worst case is minutes of
14
+ // serial failover across provider keys — so a sick window used to pin the CLI on
15
+ // "Working..." forever. Every call below carries its own ceiling: slow becomes a
16
+ // `timeout` JaitusError (retryable by the agent loop, shown with a request id)
17
+ // 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
+ const MESSAGES_TIMEOUT_MS = 10 * 60 * 1000; // streamed turns can legitimately run minutes
20
+ const API_TIMEOUT_MS = 30 * 1000; // dashboard calls are small JSON
21
+
22
+ // Combine an optional caller signal with a timeout into one signal.
23
+ // Manual combine (not AbortSignal.any): .any needs Node 20.3+, this works everywhere.
24
+ function withTimeout(signal, ms) {
25
+ const ctrl = new AbortController();
26
+ let timedOut = false;
27
+ const timer = setTimeout(() => {
28
+ timedOut = true;
29
+ try { ctrl.abort(new DOMException('Request timed out', 'TimeoutError')); }
30
+ catch { try { ctrl.abort(); } catch { /* already gone */ } }
31
+ }, ms);
32
+ if (timer.unref) timer.unref();
33
+ const onAbort = () => {
34
+ clearTimeout(timer);
35
+ try { ctrl.abort(signal.reason); } catch { try { ctrl.abort(); } catch { /* already gone */ } }
36
+ };
37
+ if (signal) {
38
+ if (signal.aborted) onAbort();
39
+ else signal.addEventListener('abort', onAbort, { once: true });
40
+ }
41
+ return {
42
+ signal: ctrl.signal,
43
+ // True only when OUR timer fired. A user abort also lands here as an AbortError,
44
+ // but signal.aborted is set then — never mistake Esc for a timeout.
45
+ timedOut: () => timedOut && !signal?.aborted,
46
+ cleanup: () => { clearTimeout(timer); signal?.removeEventListener?.('abort', onAbort); },
47
+ };
48
+ }
49
+
50
+ function timeoutError() {
51
+ return new JaitusError({ code: 'timeout', status: 504, message: 'Jaitus took too long to respond. Try again.' });
52
+ }
53
+
13
54
  // The context window the gateway last advertised, or null before it has been asked.
14
55
  let contextWindowSeen = null;
15
56
  export const advertisedContextWindow = () => contextWindowSeen;
@@ -28,45 +69,63 @@ export function createClient() {
28
69
 
29
70
  /** POST /v1/messages. Returns the raw Response so the caller can stream it. */
30
71
  async messages(body, { signal } = {}) {
72
+ // The timer is deliberately NOT cleared when headers arrive: a stall can happen
73
+ // mid-body too (headers fast, tokens never come), and only the stream reader ends
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);
31
77
  try {
32
78
  return await withAuth((token) => fetch(`${baseUrl}/v1/messages`, {
33
79
  method: 'POST',
34
80
  headers: headers(token),
35
81
  body: JSON.stringify(body),
36
- signal,
82
+ signal: t.signal,
37
83
  }));
38
84
  } catch (cause) {
39
- if (cause?.name === 'JaitusError' || cause?.name === 'AbortError') throw cause;
85
+ if (cause?.name === 'JaitusError') throw cause;
86
+ if (t.timedOut()) throw timeoutError();
87
+ if (cause?.name === 'AbortError') throw cause;
40
88
  throw fromNetwork(cause);
41
89
  }
42
90
  },
43
91
 
44
92
  /** A dashboard API call, returning parsed JSON. */
45
93
  async api(pathname, init = {}) {
94
+ const t = withTimeout(init.signal, API_TIMEOUT_MS);
46
95
  let res;
47
96
  try {
48
97
  res = await withAuth((token) => fetch(`${baseUrl}/api${pathname}`, {
49
98
  ...init,
50
99
  headers: { ...headers(token), ...(init.headers || {}) },
100
+ signal: t.signal,
51
101
  }));
102
+ const body = await res.json().catch(() => ({}));
103
+ return { ok: res.ok, status: res.status, body };
52
104
  } catch (cause) {
53
105
  if (cause?.name === 'JaitusError') throw cause;
106
+ if (t.timedOut()) throw timeoutError();
107
+ if (cause?.name === 'AbortError') throw cause;
54
108
  throw fromNetwork(cause);
109
+ } finally {
110
+ t.cleanup();
55
111
  }
56
- const body = await res.json().catch(() => ({}));
57
- return { ok: res.ok, status: res.status, body };
58
112
  },
59
113
 
60
114
  /** Model ids the gateway advertises. */
61
115
  async models() {
116
+ const t = withTimeout(undefined, API_TIMEOUT_MS);
62
117
  let res;
63
118
  try {
64
- res = await withAuth((token) => fetch(`${baseUrl}/v1/models`, { headers: headers(token) }));
119
+ res = await withAuth((token) => fetch(`${baseUrl}/v1/models`, { headers: headers(token), signal: t.signal }));
65
120
  } catch (cause) {
121
+ t.cleanup();
66
122
  if (cause?.name === 'JaitusError') throw cause;
123
+ if (t.timedOut()) throw timeoutError();
124
+ if (cause?.name === 'AbortError') throw cause;
67
125
  throw fromNetwork(cause);
68
126
  }
69
127
  const body = await res.json().catch(() => ({}));
128
+ t.cleanup();
70
129
  if (!res.ok) throw fromGatewayBody(res.status, body, { requestId: res.headers.get('x-request-id') || '' });
71
130
  const models = (body?.data || []).map((m) => m?.id).filter(Boolean);
72
131
  // How much conversation fits, as the gateway computed it for this account.
package/src/index.js CHANGED
@@ -1181,7 +1181,8 @@ async function session({ flags }) {
1181
1181
  ledger.note('compacted', `The conversation was automatically summarised to free context: ${dropped} messages became one summary. Detail you are not shown above is genuinely gone — ask or re-read rather than assuming.`);
1182
1182
  } catch (error) {
1183
1183
  // Not fatal: the turn can still run, it just runs closer to the ceiling.
1184
- tui.notice?.(`Auto-compaction failed (${error.message}) — continuing without it. /compact retries.`, 'warn');
1184
+ const id = isJaitusError(error) && error.requestId ? ` (request id ${error.requestId})` : '';
1185
+ tui.notice?.(`Auto-compaction failed (${error.message}${id}) — continuing without it. /compact retries.`, 'warn');
1185
1186
  ledger.note('compact-failed', `Automatic context compaction failed (${error.message}); the conversation is close to the context limit.`);
1186
1187
  } finally {
1187
1188
  tui.clearStatus?.();
@@ -1830,7 +1831,11 @@ async function slashCommand(verb, args, ctx) {
1830
1831
  ok('Conversation compacted. Context freed.');
1831
1832
  try { ctx.resetContext?.(); } catch { /* header keeps last value */ }
1832
1833
  } catch (e) {
1833
- fail(`Compact failed: ${e.message}`);
1834
+ // The request id is the only thing that ties this failure to a backend log
1835
+ // line. fail() takes a string, so a JaitusError passed bare would shed it —
1836
+ // and "where do I get the request id" is exactly what that produces.
1837
+ const id = isJaitusError(e) && e.requestId ? ` (request id ${e.requestId})` : '';
1838
+ fail(`Compact failed: ${e.message}${id}`);
1834
1839
  } finally {
1835
1840
  spin.stop();
1836
1841
  }