jaitus 0.3.8 → 0.3.9

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.9",
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.