jaitus 0.3.0 → 0.3.2

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.0",
3
+ "version": "0.3.2",
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/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",
19
+ "test": "node tests/source-sanity.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",
20
20
  "preview": "node scripts/tui-preview.js",
21
21
  "prepublishOnly": "npm test"
22
22
  },
package/src/agent/loop.js CHANGED
@@ -21,7 +21,9 @@
21
21
  import { byName, toolSchemas, ToolError, readImageForModel } from './tools.js';
22
22
  import { PermissionDenied } from './permissions.js';
23
23
  import path from 'node:path';
24
- import { loadContext, CONTEXT_FILE } from '../project.js';
24
+ import fs from 'node:fs';
25
+ import os from 'node:os';
26
+ import { loadContext, loadIgnore, CONTEXT_FILE } from '../project.js';
25
27
  import { c, out, write, spinner, glyph } from '../ui.js';
26
28
  import { discoverMcpTools } from '../mcp.js';
27
29
  import { unifiedDiff } from '../diff.js';
@@ -29,6 +31,23 @@ import { fromResponse, fromGatewayBody } from '../errors.js';
29
31
 
30
32
  const MAX_ITERATIONS = 40;
31
33
 
34
+ // Room for a long answer or a large file in one reply. The models behind Jaitus reason
35
+ // before they answer, and that reasoning is spent from the same budget — at 8192 a long
36
+ // answer, or any file of a few hundred lines, was cut off partway.
37
+ export const MAX_OUTPUT_TOKENS = 32000;
38
+
39
+ // A reply that stopped before it finished — the output limit, or a connection dropped
40
+ // somewhere between the provider and here — is continued, not presented as the answer.
41
+ // Bounded, so a model that never finishes cannot loop on the user's allowance.
42
+ export const MAX_CONTINUATIONS = 4;
43
+ export const CONTINUE_NOTE = 'Your previous reply was cut off before it finished (an output limit or a dropped connection). '
44
+ + 'Continue from exactly where it stopped: do not repeat or summarise what you already wrote, and do not mention this note. '
45
+ + 'If you were in the middle of a tool call, make that call again in full.';
46
+
47
+ // A request that failed before anything arrived (a network blip) is tried again this
48
+ // many times before it is reported.
49
+ const NETWORK_RETRIES = 2;
50
+
32
51
  /** opencode tool icon per tool, muted when done. ASCII twins included. */
33
52
  const TOOL_MARKS = {
34
53
  run_command: ['$', '$'], read_file: ['→', '>'], read_image: ['→', '>'],
@@ -43,6 +62,11 @@ const toolMark = (name) => {
43
62
 
44
63
  export const SYSTEM_PROMPT = `You are Jaitus, a coding agent running in a developer's terminal, inside their project directory.
45
64
 
65
+ You work directly on this computer. Your tools read, search, create and edit the files in
66
+ the project and run shell commands in it, and you can fetch web pages. When the user asks
67
+ about their files, their code or their machine, use the tools and answer from what you
68
+ find — never say you cannot access local files, because you can.
69
+
46
70
  How to work:
47
71
  - Investigate before you change anything. Read the files you are about to edit; do not guess at their contents.
48
72
  - Prefer edit_file over write_file for existing files. Keep changes minimal and in the style of the surrounding code.
@@ -65,10 +89,66 @@ How to talk:
65
89
  * Fenced and labelled rather than concatenated, so instructions written by whoever owns
66
90
  * the repo are visibly the repo's and cannot be mistaken for part of Jaitus's own rules.
67
91
  */
92
+ /**
93
+ * Where the agent is: the facts it would otherwise guess at, and guess wrong.
94
+ *
95
+ * Without these, the model did not know which folder it was in, which OS its commands
96
+ * would run on (cmd.exe and /bin/sh disagree about almost everything), or today's date —
97
+ * and a model that does not know where it is tends to say it cannot see your files. The
98
+ * listing is the top level only, names not contents, and respects .jaitusignore.
99
+ */
100
+ export function environmentBlock(cwd, { now = new Date(), platform = process.platform } = {}) {
101
+ if (!cwd) return '';
102
+ const osName = { win32: 'Windows', darwin: 'macOS', linux: 'Linux' }[platform] || platform;
103
+ const shell = platform === 'win32' ? 'cmd.exe (run_command uses it)' : '/bin/sh (run_command uses it)';
104
+ // The repository may start above the working directory, and .git may be a file that
105
+ // points elsewhere (a worktree or submodule).
106
+ let branch = null;
107
+ for (let dir = cwd, i = 0; i < 40; i++) {
108
+ const dotGit = path.join(dir, '.git');
109
+ try {
110
+ let gitDir = dotGit;
111
+ if (fs.statSync(dotGit).isFile()) {
112
+ const m = /^gitdir:\s*(.+)$/m.exec(fs.readFileSync(dotGit, 'utf8'));
113
+ if (m) gitDir = path.resolve(dir, m[1].trim());
114
+ }
115
+ const head = fs.readFileSync(path.join(gitDir, 'HEAD'), 'utf8').trim();
116
+ branch = head.startsWith('ref: refs/heads/') ? head.slice(16) : `detached at ${head.slice(0, 7)}`;
117
+ break;
118
+ } catch { /* not here; look one level up */ }
119
+ const parent = path.dirname(dir);
120
+ if (parent === dir) break;
121
+ dir = parent;
122
+ }
123
+ let listing = '';
124
+ try {
125
+ const ignore = loadIgnore(cwd);
126
+ const SKIP = new Set(['.git', 'node_modules']);
127
+ const names = fs.readdirSync(cwd, { withFileTypes: true })
128
+ .filter((d) => !SKIP.has(d.name) && !ignore.matches(d.isDirectory() ? `${d.name}/` : d.name) && !ignore.matches(d.name))
129
+ .map((d) => (d.isDirectory() ? `${d.name}/` : d.name))
130
+ .sort((a, b) => (b.endsWith('/') - a.endsWith('/')) || a.localeCompare(b));
131
+ const MAX = 60;
132
+ listing = names.slice(0, MAX).join('\n') + (names.length > MAX ? `\n… and ${names.length - MAX} more` : '');
133
+ } catch { /* unreadable: the tools will say why */ }
134
+ return `
135
+
136
+ <environment>
137
+ Working directory: ${cwd}
138
+ Platform: ${osName} (${os.release()})
139
+ Shell: ${shell}
140
+ Today: ${now.toISOString().slice(0, 10)}
141
+ Git: ${branch ? `yes, branch ${branch}` : 'no repository at this folder'}
142
+ Top level of the working directory:
143
+ ${listing || '(empty)'}
144
+ </environment>`;
145
+ }
146
+
68
147
  export function systemPrompt(cwd) {
69
148
  const project = cwd ? loadContext(cwd) : null;
70
- if (!project) return SYSTEM_PROMPT;
71
- return `${SYSTEM_PROMPT}
149
+ const base = `${SYSTEM_PROMPT}${environmentBlock(cwd)}`;
150
+ if (!project) return base;
151
+ return `${base}
72
152
 
73
153
  The project you are working in ships a ${CONTEXT_FILE} with notes for you. Follow it where
74
154
  it applies, and prefer what it says over your own assumptions about this codebase.
@@ -82,7 +162,7 @@ ${project}
82
162
  async function streamTurn({ client, messages, model, system, tools, onText, onToolStart, signal }) {
83
163
  const res = await client.messages({
84
164
  model,
85
- max_tokens: 8192,
165
+ max_tokens: MAX_OUTPUT_TOKENS,
86
166
  system: system || SYSTEM_PROMPT,
87
167
  tools,
88
168
  messages,
@@ -95,6 +175,10 @@ async function streamTurn({ client, messages, model, system, tools, onText, onTo
95
175
  // Blocks are keyed by the index the stream gives them, because they interleave.
96
176
  const blocks = new Map();
97
177
  let stopReason = null;
178
+ // Whether the stream ended the way a finished message ends. A stream that just stops
179
+ // — no message_stop, no stop reason — was cut, and used to be taken as a complete,
180
+ // silent answer: the "it stops midway for no reason" bug.
181
+ let sawStop = false;
98
182
  const usage = { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 };
99
183
  // Usage can arrive at the START of a stream (Anthropic-format providers) or only at
100
184
  // the END, in message_delta (OpenAI-format providers, translated by the gateway —
@@ -145,6 +229,9 @@ async function streamTurn({ client, messages, model, system, tools, onText, onTo
145
229
  if (event.delta?.stop_reason) stopReason = event.delta.stop_reason;
146
230
  takeUsage(event.usage);
147
231
  break;
232
+ case 'message_stop':
233
+ sawStop = true;
234
+ break;
148
235
  default:
149
236
  break;
150
237
  }
@@ -159,21 +246,31 @@ async function streamTurn({ client, messages, model, system, tools, onText, onTo
159
246
  // independently would corrupt it. The decoder holds the partial bytes until complete.
160
247
  const decoder = new TextDecoder();
161
248
 
162
- for await (const chunk of res.body) {
163
- buffer += decoder.decode(chunk, { stream: true });
164
- const frames = buffer.split('\n\n');
165
- buffer = frames.pop() || '';
166
- for (const frame of frames) {
167
- for (const line of frame.split('\n')) {
168
- if (!line.startsWith('data:')) continue;
169
- const raw = line.slice(5).trim();
170
- if (!raw || raw === '[DONE]') continue;
171
- try { handle(JSON.parse(raw)); } catch { /* keepalive or partial */ }
249
+ let cut = false;
250
+ try {
251
+ for await (const chunk of res.body) {
252
+ buffer += decoder.decode(chunk, { stream: true });
253
+ const frames = buffer.split('\n\n');
254
+ buffer = frames.pop() || '';
255
+ for (const frame of frames) {
256
+ for (const line of frame.split('\n')) {
257
+ if (!line.startsWith('data:')) continue;
258
+ const raw = line.slice(5).trim();
259
+ if (!raw || raw === '[DONE]') continue;
260
+ try { handle(JSON.parse(raw)); } catch { /* keepalive or partial */ }
261
+ }
172
262
  }
263
+ if (streamError) break;
173
264
  }
174
- if (streamError) break;
265
+ } catch (e) {
266
+ if (signal?.aborted || e?.name === 'AbortError') throw e;
267
+ // The connection dropped mid-answer. What arrived is kept and continued from; with
268
+ // nothing received yet, the caller retries the request instead.
269
+ if (!blocks.size) throw e;
270
+ cut = true;
175
271
  }
176
272
  if (streamError) throw streamError;
273
+ if (cut || (!sawStop && !stopReason)) stopReason = 'cut';
177
274
 
178
275
  const content = [...blocks.entries()]
179
276
  .sort((a, b) => a[0] - b[0])
@@ -222,6 +319,12 @@ export async function runAgent({
222
319
  ...Object.fromEntries(mcp.map((tool) => [tool.name, tool])),
223
320
  };
224
321
 
322
+ let continuations = 0;
323
+ let networkRetries = 0;
324
+ // True while the previous reply is being continued: its text is still on screen as
325
+ // one live message, and the continuation must extend it rather than start another.
326
+ let continuing = false;
327
+
225
328
  for (let iteration = 0; iteration < maxIterations; iteration++) {
226
329
  let streamed = false;
227
330
  const spin = tui ? { stop: () => {}, update: () => {} } : (silent ? { stop: () => {}, update: () => {} } : spinner(glyph('Working…', 'Working...')));
@@ -260,6 +363,15 @@ export async function runAgent({
260
363
  spin.stop();
261
364
  // Esc during streaming: stop mid-sentence, keep what landed, don't treat it
262
365
  // as an error. The partial text is already on screen.
366
+ // A network failure before anything arrived: try again, briefly, before reporting.
367
+ // Anything the gateway itself refused (a limit, a sign-in problem) has a status and
368
+ // is reported at once — retrying it would only repeat the refusal.
369
+ if (!(signal?.aborted || e?.name === 'AbortError') && !e?.status && networkRetries < NETWORK_RETRIES) {
370
+ networkRetries += 1;
371
+ await new Promise((r) => setTimeout(r, 800 * networkRetries));
372
+ iteration -= 1;
373
+ continue;
374
+ }
263
375
  if (signal?.aborted || e?.name === 'AbortError') {
264
376
  if (tui) tui.flushStream();
265
377
  if (streamed && !silent) out('');
@@ -271,8 +383,16 @@ export async function runAgent({
271
383
  throw e;
272
384
  }
273
385
  spin.stop();
274
- if (tui) tui.flushStream();
275
- if (streamed) { if (tui) tui.printStream('\n'); else if (!silent && !onText) out(''); }
386
+ networkRetries = 0;
387
+ const incomplete = turn.stopReason === 'max_tokens' || turn.stopReason === 'cut';
388
+ // A tool call cut off halfway has JSON that is not whole; it is dropped and the model
389
+ // is asked to make it again.
390
+ if (incomplete) turn.content = turn.content.filter((b) => b.type !== 'tool_use' || b.input?.__parse_error === undefined);
391
+ const willContinue = incomplete && continuations < MAX_CONTINUATIONS && !turn.content.some((b) => b.type === 'tool_use');
392
+ if (!willContinue) {
393
+ if (tui) tui.flushStream();
394
+ if (streamed || continuing) { if (tui) tui.printStream('\n'); else if (!silent && !onText) out(''); }
395
+ }
276
396
 
277
397
  totals.input += turn.usage.input_tokens || 0;
278
398
  totals.cached += (turn.usage.cache_read_input_tokens || 0) + (turn.usage.cache_creation_input_tokens || 0);
@@ -283,6 +403,24 @@ export async function runAgent({
283
403
  // appeared; now it moves after every step of the turn.
284
404
  tui?.setTurnTokens?.(totals.input + totals.cached + totals.output);
285
405
 
406
+ if (willContinue) {
407
+ continuations += 1;
408
+ continuing = true;
409
+ const text = turn.content.filter((b) => b.type === 'text');
410
+ if (text.length) messages.push({ role: 'assistant', content: text });
411
+ messages.push({ role: 'user', content: [{ type: 'text', text: CONTINUE_NOTE }] });
412
+ onEvent?.({ type: 'continued', reason: turn.stopReason, count: continuations });
413
+ continue;
414
+ }
415
+ continuing = false;
416
+ if (incomplete && !turn.content.some((b) => b.type === 'tool_use')) {
417
+ // Still unfinished after every continuation: say so, rather than let a partial
418
+ // answer pass as a whole one.
419
+ const why = 'The reply was still unfinished after several continuations. Ask for the rest, or narrow the request.';
420
+ if (tui?.notice) tui.notice(why, 'warn');
421
+ else if (!silent) out(c.yellow(` ${why}`));
422
+ }
423
+
286
424
  // Append the assistant turn verbatim — tool_use blocks and all.
287
425
  messages.push({ role: 'assistant', content: turn.content });
288
426
  onEvent?.({ type: 'assistant_turn', content: turn.content, usage: turn.usage, iteration });
@@ -104,16 +104,15 @@ function awaitCallback(server, expectedState) {
104
104
  });
105
105
  }
106
106
 
107
- export default async function login(argv) {
107
+ /**
108
+ * The browser sign-in, with no output of its own.
109
+ *
110
+ * The command below prints it to the terminal; the app view shows the same steps in its
111
+ * transcript. Reports progress through `onUrl` (the link to open, shown in case the
112
+ * browser does not) and `onStatus`. Resolves to { ok: true, user } or { ok: false, message }.
113
+ */
114
+ export async function signIn({ onUrl = () => {}, onStatus = () => {} } = {}) {
108
115
  const { baseUrl, siteUrl } = endpoints();
109
-
110
- const existing = loadCredentials();
111
- if (existing?.refreshToken && !argv.includes('--force')) {
112
- out(`Already signed in as ${c.bold(existing.user?.email || 'your account')}.`);
113
- const again = await ask(`${c.dim('Sign in again? [y/N] ')}`);
114
- if (again.toLowerCase() !== 'y') return 0;
115
- }
116
-
117
116
  const state = crypto.randomBytes(24).toString('base64url');
118
117
  // PKCE (S256): the challenge goes out through the browser, the verifier stays here and
119
118
  // is only ever sent in the final exchange.
@@ -140,27 +139,18 @@ export default async function login(argv) {
140
139
  url.searchParams.set('device', device);
141
140
  url.searchParams.set('platform', process.platform);
142
141
 
143
- out('');
144
- out(` ${c.brand('Jaitus')} — sign in`);
145
- out('');
146
- out(` Opening ${c.cyan(siteUrl + '/cli-auth')} in your browser.`);
147
- out(` ${c.dim('If it does not open, paste this link:')}`);
148
- out(` ${c.dim(url.toString())}`);
149
- out('');
150
-
142
+ onUrl(url.toString(), `${siteUrl}/cli-auth`);
151
143
  openBrowser(url.toString());
152
- const spin = spinner(glyph('Waiting for you to authorise in the browser…', 'Waiting for you to authorise in the browser...'));
144
+ onStatus('waiting');
153
145
 
154
146
  let code;
155
147
  try {
156
148
  ({ code } = await awaitCallback(server, state));
157
149
  } catch (e) {
158
- spin.stop();
159
150
  server.close();
160
- fail(e.message);
161
- return 1;
151
+ return { ok: false, message: e.message };
162
152
  }
163
- spin.update(glyph('Finishing sign-in…', 'Finishing sign-in...'));
153
+ onStatus('finishing');
164
154
 
165
155
  try {
166
156
  const res = await fetch(`${baseUrl}/api/auth/cli/exchange`, {
@@ -169,13 +159,11 @@ export default async function login(argv) {
169
159
  body: JSON.stringify({ code, verifier }),
170
160
  });
171
161
  const body = await res.json().catch(() => ({}));
172
- spin.stop();
173
162
  server.close();
174
163
 
175
164
  if (!res.ok || !body?.data?.accessToken || !body?.data?.refreshToken) {
176
165
  // Our own sign-in endpoint's wording, or a plain fallback — never a raw body.
177
- fail(typeof body?.message === 'string' && body.message ? body.message : 'Sign-in did not complete. Run `jaitus login` again.');
178
- return 1;
166
+ return { ok: false, message: typeof body?.message === 'string' && body.message ? body.message : 'Sign-in did not complete. Try again.' };
179
167
  }
180
168
 
181
169
  saveCredentials({
@@ -188,14 +176,44 @@ export default async function login(argv) {
188
176
  createdAt: new Date().toISOString(),
189
177
  });
190
178
 
191
- const who = body.data.user;
192
- ok(`Signed in as ${c.bold(who?.email || 'your account')}${who?.isPaid ? '' : c.dim(' (free plan)')}`);
193
- info(`Run ${c.bold('jaitus')} in a project to start, or ${c.bold('jaitus --help')} to see everything.`);
194
- return 0;
195
- } catch (e) {
196
- spin.stop();
179
+ return { ok: true, user: body.data.user || null };
180
+ } catch {
197
181
  server.close();
198
- fail('Could not reach Jaitus. Check your connection and run `jaitus login` again.');
182
+ return { ok: false, message: 'Could not reach Jaitus. Check your connection and try again.' };
183
+ }
184
+ }
185
+
186
+ export default async function login(argv, { then = 'hint' } = {}) {
187
+ const existing = loadCredentials();
188
+ if (existing?.refreshToken && !argv.includes('--force')) {
189
+ out(`Already signed in as ${c.bold(existing.user?.email || 'your account')}.`);
190
+ const again = await ask(`${c.dim('Sign in again? [y/N] ')}`);
191
+ if (again.toLowerCase() !== 'y') return 0;
192
+ }
193
+
194
+ let spin = null;
195
+ const result = await signIn({
196
+ onUrl: (url, shown) => {
197
+ out('');
198
+ out(` ${c.brand('Jaitus')} — sign in`);
199
+ out('');
200
+ out(` Opening ${c.cyan(shown)} in your browser.`);
201
+ out(` ${c.dim('If it does not open, paste this link:')}`);
202
+ out(` ${c.dim(url)}`);
203
+ out('');
204
+ },
205
+ onStatus: (s) => {
206
+ if (s === 'waiting') spin = spinner(glyph('Waiting for you to authorise in the browser…', 'Waiting for you to authorise in the browser...'));
207
+ else spin?.update(glyph('Finishing sign-in…', 'Finishing sign-in...'));
208
+ },
209
+ });
210
+ spin?.stop();
211
+ if (!result.ok) {
212
+ fail(`${result.message}${/login/.test(result.message) ? '' : ` Run ${c.bold('jaitus login')} to try again.`}`);
199
213
  return 1;
200
214
  }
215
+ const who = result.user;
216
+ ok(`Signed in as ${c.bold(who?.email || 'your account')}${who?.isPaid ? '' : c.dim(' (free plan)')}`);
217
+ if (then === 'hint') info(`Run ${c.bold('jaitus')} in a project to start, or ${c.bold('jaitus --help')} to see everything.`);
218
+ return 0;
201
219
  }
@@ -1,12 +1,21 @@
1
1
  // ─── jaitus update ──────────────────────────────────────────────────────────────
2
2
  //
3
- // Checks the registry and tells you. It deliberately does NOT run the install itself:
4
- // the CLI may have been installed globally, with npx, through a version manager, or
5
- // vendored into a repo, and a tool that guesses wrong there either fails confusingly or
6
- // modifies something it was never asked to modify.
3
+ // Checks the registry, and installs the new version when it can tell HOW this copy was
4
+ // installed: a global npm install is upgraded with the same npm, after asking. Anything
5
+ // else — npx, a version manager, a copy vendored into a repo — gets the command to run
6
+ // instead, because a tool that guesses wrong there either fails confusingly or modifies
7
+ // something it was never asked to modify.
8
+ //
9
+ // A global install never updates itself, so the app also checks once a day, in the
10
+ // background, and says when a newer version is out (the update-notifier pattern npm and
11
+ // gh use). JAITUS_NO_UPDATE_CHECK=1, NO_UPDATE_NOTIFIER or CI turns that off.
7
12
 
8
- import { c, out, ok, info, warn, present } from '../ui.js';
9
- import { version } from '../config.js';
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { spawn } from 'node:child_process';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { c, out, ok, info, warn, fail, present, askKey } from '../ui.js';
18
+ import { version, HOME } from '../config.js';
10
19
 
11
20
  const REGISTRY = process.env.JAITUS_REGISTRY || 'https://registry.npmjs.org';
12
21
  const TIMEOUT_MS = 5000;
@@ -46,6 +55,74 @@ export async function latestVersion() {
46
55
  return (await checkRegistry()).version;
47
56
  }
48
57
 
58
+ const CHECK_FILE = path.join(HOME, 'update-check.json');
59
+ const DAY_MS = 24 * 60 * 60 * 1000;
60
+
61
+ /** Whether the background check is allowed to run at all. */
62
+ export function updateCheckEnabled(env = process.env) {
63
+ return !(env.JAITUS_NO_UPDATE_CHECK || env.NO_UPDATE_NOTIFIER || env.CI);
64
+ }
65
+
66
+ /**
67
+ * The newest published version, asked of the registry at most once a day.
68
+ *
69
+ * Cached so opening the app is never slowed by, or dependent on, the network: a failed
70
+ * check is remembered too, and simply tried again tomorrow.
71
+ */
72
+ export async function cachedLatest({ now = Date.now(), file = CHECK_FILE, check = checkRegistry } = {}) {
73
+ try {
74
+ const cached = JSON.parse(fs.readFileSync(file, 'utf8'));
75
+ if (cached && now - Number(cached.checkedAt) < DAY_MS) return cached.latest || null;
76
+ } catch { /* first run, or unreadable: ask */ }
77
+ const { version: latest } = await check();
78
+ try {
79
+ fs.mkdirSync(path.dirname(file), { recursive: true });
80
+ fs.writeFileSync(file, JSON.stringify({ checkedAt: now, latest: latest || null }));
81
+ } catch { /* read-only home: check again next time */ }
82
+ return latest || null;
83
+ }
84
+
85
+ /** One line for the app to show when a newer version is out, or null. */
86
+ export async function updateNotice(opts = {}) {
87
+ if (!updateCheckEnabled(opts.env)) return null;
88
+ const current = opts.current || version();
89
+ const latest = await cachedLatest(opts).catch(() => null);
90
+ if (!latest || !isNewer(latest, current)) return null;
91
+ return `Jaitus v${latest} is out — you have v${current}. Quit and run \`jaitus update\` to install it.`;
92
+ }
93
+
94
+ function run(cmd, args, { capture = false } = {}) {
95
+ return new Promise((resolve) => {
96
+ // npm is npm.cmd on Windows, which spawn cannot start without a shell. The arguments
97
+ // here are fixed strings (a version number from the registry at most), so they are
98
+ // joined into one command line rather than passed alongside shell: true, which Node
99
+ // deprecates because it does not escape them.
100
+ const stdio = capture ? ['ignore', 'pipe', 'ignore'] : 'inherit';
101
+ const child = process.platform === 'win32'
102
+ ? spawn([cmd, ...args].join(' '), { shell: true, stdio })
103
+ : spawn(cmd, args, { stdio });
104
+ let stdout = '';
105
+ child.stdout?.on('data', (d) => { stdout += d; });
106
+ child.on('error', () => resolve({ code: -1, stdout }));
107
+ child.on('close', (code) => resolve({ code, stdout }));
108
+ });
109
+ }
110
+
111
+ /**
112
+ * How this copy was installed: 'npm-global', 'npx', or 'other'.
113
+ *
114
+ * Decided by where this file actually lives, compared with npm's own global folder —
115
+ * not by guessing from environment variables.
116
+ */
117
+ export async function installMethod({ here = fileURLToPath(import.meta.url), npmRoot = null } = {}) {
118
+ const real = (() => { try { return fs.realpathSync(here); } catch { return here; } })();
119
+ const norm = (p) => path.resolve(p).toLowerCase();
120
+ if (/[\\/]_npx[\\/]/.test(real)) return 'npx';
121
+ const root = npmRoot ?? (await run('npm', ['root', '-g'], { capture: true })).stdout.trim();
122
+ if (root && norm(real).startsWith(norm(path.join(root, 'jaitus')) + path.sep.toLowerCase())) return 'npm-global';
123
+ return 'other';
124
+ }
125
+
49
126
  /**
50
127
  * Plain semver comparison — is `a` newer than `b`?
51
128
  * Prerelease tags are treated as older than the release they lead to, which is what
@@ -67,7 +144,61 @@ export function isNewer(a, b) {
67
144
  return false;
68
145
  }
69
146
 
70
- export default async function update(argv = [], { json = false } = {}) {
147
+ /**
148
+ * Update on launch, then reopen: every time `jaitus` starts, the registry is asked
149
+ * (with a short timeout, so a slow network costs at most that); when a newer version is
150
+ * out and this copy is a global npm install, it is installed and the same command is
151
+ * started again on the new version.
152
+ *
153
+ * It never blocks the session on a failure. A failed install (commonly: a system-wide
154
+ * Node on macOS or Linux, where npm -g needs sudo) says so once and is not retried for a
155
+ * day, so a machine that cannot update does not pay for trying on every start.
156
+ *
157
+ * Off with `jaitus config autoUpdate off`, JAITUS_NO_AUTOUPDATE=1, NO_UPDATE_NOTIFIER or CI.
158
+ * Returns the child's exit code when it relaunched, or null to carry on in this process.
159
+ */
160
+ export async function autoUpdate({ argv = [], settings = {}, env = process.env, current = version(), log = out,
161
+ check = () => checkRegistry(), method = () => installMethod(), install = (v) => run('npm', ['install', '-g', `jaitus@${v}`], { capture: true }),
162
+ relaunch = defaultRelaunch, now = Date.now(), failFile = path.join(HOME, 'update-failed.json') } = {}) {
163
+ if (env.JAITUS_UPDATED_FROM) return null; // just relaunched: never loop
164
+ if (!updateCheckEnabled(env) || env.JAITUS_NO_AUTOUPDATE || settings.autoUpdate === false) return null;
165
+ if (!process.stdout.isTTY || !process.stdin.isTTY) return null; // scripts and pipes get a stable version
166
+ const first = argv.find((a) => !a.startsWith('-'));
167
+ if (['update', 'completion', 'help', 'logout'].includes(first) || argv.some((a) => ['--version', '-v', '--help', '-h', '--print', '-p'].includes(a))) return null;
168
+ try {
169
+ const failed = JSON.parse(fs.readFileSync(failFile, 'utf8'));
170
+ if (now - Number(failed.at) < DAY_MS) return null;
171
+ } catch { /* no recent failure */ }
172
+
173
+ const { version: latest } = await Promise.race([
174
+ check(),
175
+ // unref: a quick command must not sit waiting for this timer after the answer came.
176
+ new Promise((resolve) => setTimeout(() => resolve({ version: null }), 1500).unref()),
177
+ ]);
178
+ if (!latest || !isNewer(latest, current)) return null;
179
+ if ((await method()) !== 'npm-global') return null; // the in-app notice covers these
180
+
181
+ log(` ${c.brand('Jaitus')} ${c.dim(`v${current} →`)} ${c.bold(`v${latest}`)} ${c.dim('— updating…')}`);
182
+ const { code } = await install(latest);
183
+ if (code !== 0) {
184
+ try { fs.mkdirSync(path.dirname(failFile), { recursive: true }); fs.writeFileSync(failFile, JSON.stringify({ at: now, version: latest })); } catch { /* best effort */ }
185
+ log(` ${c.yellow('!')} ${c.dim('Could not update automatically — continuing on')} v${current}${c.dim('. To update:')} ${c.bold(process.platform === 'win32' ? 'npm install -g jaitus@latest' : 'sudo npm install -g jaitus@latest')}`);
186
+ return null;
187
+ }
188
+ log(` ${c.accent('✓')} ${c.dim('Updated — reopening')}`);
189
+ return relaunch({ ...env, JAITUS_UPDATED_FROM: current });
190
+ }
191
+
192
+ /** Start the same command again on the freshly installed version, in this terminal. */
193
+ function defaultRelaunch(env) {
194
+ return new Promise((resolve) => {
195
+ const child = spawn(process.execPath, [...process.execArgv, process.argv[1], ...process.argv.slice(2)], { stdio: 'inherit', env });
196
+ child.on('error', () => resolve(null));
197
+ child.on('exit', (code, signal) => resolve(signal ? 1 : (code ?? 0)));
198
+ });
199
+ }
200
+
201
+ export default async function update(argv = [], { json = false, interactive = true } = {}) {
71
202
  const current = version();
72
203
  const { version: latest, status } = await checkRegistry();
73
204
 
@@ -89,9 +220,36 @@ export default async function update(argv = [], { json = false } = {}) {
89
220
  out('');
90
221
  out(` ${c.bold(`v${latest}`)} is available ${c.dim(`— you have v${current}`)}`);
91
222
  out('');
92
- out(` ${c.dim('global install')} ${c.bold('npm install -g jaitus')}`);
223
+ });
224
+ if (!available || json) return 0;
225
+
226
+ const manual = () => {
227
+ out(` ${c.dim('global install')} ${c.bold('npm install -g jaitus@latest')}`);
93
228
  out(` ${c.dim('no install')} ${c.bold('npx jaitus@latest')}`);
94
229
  out('');
95
- });
230
+ };
231
+ // Inside the app the running copy cannot be swapped out from under itself.
232
+ if (!interactive) { info('Quit Jaitus, then run it from your shell:'); manual(); return 0; }
233
+
234
+ const method = await installMethod();
235
+ if (method !== 'npm-global') {
236
+ info(method === 'npx' ? 'You are running it through npx — use @latest to get the new version:' : 'This copy was not installed with npm -g, so update it the way you installed it, or:');
237
+ manual();
238
+ return 0;
239
+ }
240
+ const yes = argv.includes('--yes') || argv.includes('-y');
241
+ if (!yes) {
242
+ if (!process.stdin.isTTY) { manual(); return 0; }
243
+ const answer = await askKey(` Install v${latest} now? ${c.dim('[Y/n]')} `, ['y', 'n', '\r', '\n']);
244
+ if (answer === 'n') { manual(); return 0; }
245
+ }
246
+ out(` ${c.dim('running')} npm install -g jaitus@${latest}`);
247
+ const { code } = await run('npm', ['install', '-g', `jaitus@${latest}`]);
248
+ if (code !== 0) {
249
+ fail(`npm could not install it (exit ${code}). Try it yourself — on macOS or Linux it may need sudo, or a Node version manager:`);
250
+ manual();
251
+ return 1;
252
+ }
253
+ ok(`Updated to v${latest}. Start ${c.bold('jaitus')} again to use it.`);
96
254
  return 0;
97
255
  }
package/src/errors.js CHANGED
@@ -8,8 +8,8 @@
8
8
  // recognise, because the only text that is safe to show is text we wrote.
9
9
 
10
10
  const CATALOG = {
11
- auth_required: { title: 'Not signed in', hint: 'Run `jaitus login` to sign in.' },
12
- session_expired: { title: 'Signed out', hint: 'Run `jaitus login` to sign in again.' },
11
+ auth_required: { title: 'Not signed in', hint: 'Type /login to sign in (or run `jaitus login`).' },
12
+ session_expired: { title: 'Signed out', hint: 'Type /login to sign in again (or run `jaitus login`).' },
13
13
  account_suspended: { title: 'Account suspended', hint: 'Contact support if you think this is a mistake.' },
14
14
  email_unverified: { title: 'Email not verified', hint: 'Open the verification link in your inbox, then retry.' },
15
15
  free_limit_reached: { title: 'Free prompts used up', hint: 'Subscribe on the website, then retry this turn.' },