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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jaitus",
3
- "version": "0.3.13",
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 ?? 2000 * 2 ** attempt);
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();
@@ -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
- const cleaned = input.todos.map((t) => ({
750
- task: String(t?.task || '').trim(),
751
- status: ['pending', 'in_progress', 'completed'].includes(t?.status) ? t.status : 'pending',
752
- })).filter((t) => t.task);
753
- if (!cleaned.length) throw new ToolError('The list has no usable items.');
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': 'jaitus-cli', '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
+
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
+ }
@@ -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, askKey } from '../ui.js';
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 run \`jaitus update\` to install it.`;
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 && norm(real).startsWith(norm(path.join(root, 'jaitus')) + path.sep.toLowerCase())) return 'npm-global';
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: every time `jaitus` starts, the registry is asked
177
- * (with a short timeout, so a slow network costs at most that); when a newer version is
178
- * out and this copy is a global npm install, it is installed and the same command is
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 never blocks the session on a failure. A failed install (commonly: a system-wide
182
- * Node on macOS or Linux, where npm -g needs sudo) says so once and is not retried for a
183
- * day, so a machine that cannot update does not pay for trying on every start.
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
- * Off with `jaitus config autoUpdate off`, JAITUS_NO_AUTOUPDATE=1, NO_UPDATE_NOTIFIER or CI.
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 = [], settings = {}, env = process.env, current = version(), log = out,
199
+ export async function autoUpdate({ argv = [], env = process.env, current = version(), log = out,
189
200
  check = () => checkRegistry(), method = () => installMethod(), install = null,
190
- relaunch = defaultRelaunch, now = Date.now(), failFile = path.join(HOME, 'update-failed.json'),
191
- cacheFile = CHECK_FILE, waitMs = 1500, write = (s) => process.stdout.write(s),
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 || settings.autoUpdate === false) return null;
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
- let latest = null;
210
- try {
211
- const cached = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
212
- if (cached?.latest && isNewer(cached.latest, current) && now - Number(cached.checkedAt) < 6 * 3600e3) latest = cached.latest;
213
- } catch { /* no earlier answer */ }
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
- const asked = Promise.resolve().then(check).then((r) => { if (r?.version) remember(r.version); return r; }, () => ({ version: null }));
216
- ({ version: latest } = await Promise.race([
217
- asked,
218
- // unref: a quick command must not sit waiting for this timer after the answer came.
219
- new Promise((resolve) => setTimeout(() => resolve({ version: null }), waitMs).unref()),
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(`— updating… ${s}s (this takes about a minute)`)}`),
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
- log(` ${c.yellow('!')} ${c.dim('Could not update automatically — continuing on')} v${current}${c.dim('. To update:')} ${c.bold(process.platform !== 'win32' && needsSudo ? 'sudo npm install -g jaitus@latest' : 'npm install -g jaitus@latest')}`);
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(` ${c.dim(reason.slice(0, 200))}`);
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
- const yes = argv.includes('--yes') || argv.includes('-y');
307
- if (!yes) {
308
- if (!process.stdin.isTTY) { manual(); return 0; }
309
- const answer = await askKey(` Install v${latest} now? ${c.dim('[Y/n]')} `, ['y', 'n', '\r', '\n']);
310
- if (answer === 'n') { manual(); return 0; }
311
- }
312
- out(` ${c.dim('running')} npm install -g jaitus@${latest}`);
313
- const { code } = await run('npm', ['install', '-g', `jaitus@${latest}`]);
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
  }