jaitus 0.3.2 → 0.3.4

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/README.md CHANGED
@@ -219,7 +219,7 @@ or pipes. Force either way with `JAITUS_FULLSCREEN=1` / `JAITUS_NO_FULLSCREEN=1`
219
219
  the default; without a terminal to ask in, an action that needs permission is refused
220
220
  rather than assumed.
221
221
  - Commands run with no TTY, so anything interactive exits instead of hanging forever.
222
- - The agent stops after 40 steps rather than looping on your bill.
222
+ - The agent runs until the task is done. If it repeats the same action four times without changing anything it stops and says so, rather than looping on your bill.
223
223
  - `jaitus doctor` never prints your sign-in token — only whether you are signed in.
224
224
 
225
225
  ## Signing in
package/bin/jaitus.js CHANGED
@@ -1,5 +1,10 @@
1
1
  #!/usr/bin/env node
2
- import main from '../src/index.js';
2
+ // The app is imported only after an unfinished update has completed: importing it
3
+ // statically would load a half-installed copy and crash before any check could run.
4
+ import { waitForPendingUpdate } from '../src/pendingUpdate.js';
5
+
6
+ await waitForPendingUpdate().catch(() => false);
7
+ const { default: main } = await import('../src/index.js');
3
8
 
4
9
  // Node 20 is the floor: the CLI uses global fetch, and on 18 it exists but streaming
5
10
  // bodies behave differently enough to matter. Better a clear message than a confusing
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jaitus",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
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 && node tests/mouse-e2e.js && node tests/environment.js && node tests/update.js && node tests/continuation.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 && node tests/steps.js && node tests/context-budget.js",
20
20
  "preview": "node scripts/tui-preview.js",
21
21
  "prepublishOnly": "npm test"
22
22
  },
package/src/agent/loop.js CHANGED
@@ -15,8 +15,12 @@
15
15
  // • Every tool_use must get a tool_result, including ones that failed or were refused.
16
16
  // A missing result is a malformed conversation, and the error text is also how the
17
17
  // model learns to try something else.
18
- // • A hard iteration cap. A model that gets stuck in a read-file loop would otherwise
19
- // spend real money until someone notices.
18
+ // • A stuck detector, not a step count. A model caught in a loop would otherwise spend
19
+ // real money until someone notices — but a fixed cap of 40 steps also stopped every
20
+ // long, perfectly healthy task halfway and made the user type "continue". A run now
21
+ // ends when the model finishes, or when it repeats the same call with the same input
22
+ // STUCK_REPEATS times with no file changed in between (see `stuck` below). A very
23
+ // high backstop remains so no run is unbounded.
20
24
 
21
25
  import { byName, toolSchemas, ToolError, readImageForModel } from './tools.js';
22
26
  import { PermissionDenied } from './permissions.js';
@@ -29,7 +33,8 @@ import { discoverMcpTools } from '../mcp.js';
29
33
  import { unifiedDiff } from '../diff.js';
30
34
  import { fromResponse, fromGatewayBody } from '../errors.js';
31
35
 
32
- const MAX_ITERATIONS = 40;
36
+ const MAX_ITERATIONS = 1000;
37
+ const STUCK_REPEATS = 4;
33
38
 
34
39
  // Room for a long answer or a large file in one reply. The models behind Jaitus reason
35
40
  // before they answer, and that reasoning is spent from the same budget — at 8192 a long
@@ -295,8 +300,13 @@ export async function runAgent({
295
300
  client, messages, model, cwd, permissions, signal, tui = null,
296
301
  extraSystem = '', maxIterations = MAX_ITERATIONS, silent = false,
297
302
  onText = null, onEvent = null, systemOverride = null, journal = null,
298
- subagents = true,
303
+ subagents = true, onContext = null,
299
304
  }) {
305
+ // Loop detection. A call is identified by its tool, its input, and how many files had
306
+ // been changed when it was made — so edit → test → edit → test is progress (the count
307
+ // moves), while reading the same file over and over is not.
308
+ let changes = 0;
309
+ const repeats = new Map();
300
310
  // input = fresh input, cached = prompt-cache reads (billed far cheaper), and
301
311
  // context = the real size of the last prompt sent, as the provider counted it.
302
312
  const totals = { input: 0, cached: 0, output: 0, tools: 0, context: 0 };
@@ -398,7 +408,13 @@ export async function runAgent({
398
408
  totals.cached += (turn.usage.cache_read_input_tokens || 0) + (turn.usage.cache_creation_input_tokens || 0);
399
409
  totals.output += turn.usage.output_tokens || 0;
400
410
  const promptSize = (turn.usage.input_tokens || 0) + (turn.usage.cache_read_input_tokens || 0) + (turn.usage.cache_creation_input_tokens || 0);
401
- if (promptSize) totals.context = promptSize + (turn.usage.output_tokens || 0);
411
+ if (promptSize) {
412
+ totals.context = promptSize + (turn.usage.output_tokens || 0);
413
+ // The header's context meter moves after every step. It used to be told only when
414
+ // the whole run returned, so through a long task — and after one stopped with
415
+ // Esc or an error — it sat at 0.
416
+ try { onContext?.(totals.context); } catch { /* the meter never breaks a turn */ }
417
+ }
402
418
  // The work bar's live count. It was declared and drawn but never fed, so it never
403
419
  // appeared; now it moves after every step of the turn.
404
420
  tui?.setTurnTokens?.(totals.input + totals.cached + totals.output);
@@ -447,6 +463,27 @@ export async function runAgent({
447
463
 
448
464
  if (!calls.length) { tui?.bellIfAway?.(); return { ...totals, stopReason: turn.stopReason }; }
449
465
 
466
+ // The same call, same input, nothing changed since: STUCK_REPEATS times is a loop.
467
+ // Checked before running, so the repeat that proves it is not paid for again.
468
+ const stuckOn = calls.find((call) => {
469
+ const key = `${changes}\u0000${call.name}\u0000${JSON.stringify(call.input ?? null)}`;
470
+ const n = (repeats.get(key) || 0) + 1;
471
+ repeats.set(key, n);
472
+ return n >= STUCK_REPEATS;
473
+ });
474
+ if (stuckOn) {
475
+ // Every tool_use still gets a result — the model is told why these did not run, so
476
+ // after "continue" it does not simply make the same call a fifth time.
477
+ messages.push({ role: 'user', content: calls.map((call) => result(call.id,
478
+ `Not run: this exact call was already made ${STUCK_REPEATS - 1} times with no file changed since, and the run was stopped as stuck. Do something different.`, true)) });
479
+ const label = toolMap[stuckOn.name]?.summarise ? toolMap[stuckOn.name].summarise(stuckOn.input) : stuckOn.name;
480
+ const why = `Stopped: it made the same ${stuckOn.name} call (${String(label).slice(0, 60)}) ${STUCK_REPEATS} times without changing anything, so it looks stuck. Tell it what to do differently, or say "continue".`;
481
+ if (tui?.notice) tui.notice(why, 'warn');
482
+ else if (!silent) out(c.yellow(`\n${why}`));
483
+ tui?.bellIfAway?.();
484
+ return { ...totals, stopReason: 'stuck' };
485
+ }
486
+
450
487
  // Every call gets a result, whatever happened.
451
488
  const results = [];
452
489
  for (const call of calls) {
@@ -529,6 +566,8 @@ export async function runAgent({
529
566
  tui?.recordActivity?.({ id: activityId, name: call.name, label, status: 'ok', detail: String(output).slice(0, 2000) });
530
567
  onEvent?.({ type: 'tool_result', name: call.name, input: call.input, ok: true, output });
531
568
  results.push(result(call.id, output, false));
569
+ // A changed file is progress: calls made after it are not repeats of those before.
570
+ if (['write_file', 'edit_file'].includes(call.name)) changes += 1;
532
571
  // read_image: the pixels ride along as an image block in the tool-result
533
572
  // message, so the model actually sees the picture instead of a caption of it.
534
573
  if (call.name === 'read_image' && !silent) {
@@ -587,7 +626,7 @@ export async function runAgent({
587
626
  messages.push({ role: 'user', content: withImages });
588
627
  }
589
628
 
590
- if (!silent) out(c.yellow(`\nStopped after ${maxIterations} steps without finishing. Say "continue" to keep going.`));
629
+ if (!silent) out(c.yellow(`\nStopped after ${maxIterations} steps — the safety limit for one request. Say "continue" to keep going.`));
591
630
  tui?.bellIfAway?.();
592
631
  return { ...totals, stopReason: 'max_iterations' };
593
632
  }
@@ -636,7 +675,9 @@ function makeSubagentTool({ client, model, cwd, signal, tui }) {
636
675
  tui: null,
637
676
  silent: true,
638
677
  subagents: false,
639
- maxIterations: 20,
678
+ // Read-only research across a codebase is many small reads; 20 cut it off
679
+ // mid-search. The stuck detector is what catches a loop.
680
+ maxIterations: 100,
640
681
  extraSystem: '\n\nYou are a subagent. You cannot ask questions and nobody will read your intermediate '
641
682
  + 'steps — only your final message. End with the findings themselves, with exact file paths and line '
642
683
  + 'numbers where they apply, not a description of how you looked.',
package/src/api.js CHANGED
@@ -10,6 +10,10 @@ import { fromGatewayBody, fromNetwork, JaitusError } from './errors.js';
10
10
 
11
11
  export class NotSignedIn extends Error {}
12
12
 
13
+ // The context window the gateway last advertised, or null before it has been asked.
14
+ let contextWindowSeen = null;
15
+ export const advertisedContextWindow = () => contextWindowSeen;
16
+
13
17
  export function createClient() {
14
18
  const { baseUrl } = endpoints();
15
19
  if (!signedIn()) throw new NotSignedIn('Not signed in. Run `jaitus login` first.');
@@ -63,6 +67,9 @@ export function createClient() {
63
67
  const body = await res.json().catch(() => ({}));
64
68
  if (!res.ok) throw fromGatewayBody(res.status, body, { requestId: res.headers.get('x-request-id') || '' });
65
69
  const models = (body?.data || []).map((m) => m?.id).filter(Boolean);
70
+ // How much conversation fits, as the gateway computed it for this account.
71
+ const ctx = (body?.data || []).map((m) => Number(m?.context_window)).find((n) => n > 0);
72
+ if (ctx) contextWindowSeen = ctx;
66
73
  // An empty list is a service fault, not something the user can fix — and naming
67
74
  // the gateway's model map to them would be naming our internals.
68
75
  if (!models.length) throw new JaitusError({ code: 'unavailable', status: 502 });
@@ -16,6 +16,7 @@ import { spawn } from 'node:child_process';
16
16
  import { fileURLToPath } from 'node:url';
17
17
  import { c, out, ok, info, warn, fail, present, askKey } from '../ui.js';
18
18
  import { version, HOME } from '../config.js';
19
+ import { RUNNING_FILE } from '../pendingUpdate.js';
19
20
 
20
21
  const REGISTRY = process.env.JAITUS_REGISTRY || 'https://registry.npmjs.org';
21
22
  const TIMEOUT_MS = 5000;
@@ -108,6 +109,33 @@ function run(cmd, args, { capture = false } = {}) {
108
109
  });
109
110
  }
110
111
 
112
+ /**
113
+ * `npm install -g jaitus@<version>`, run so that it cannot be cut off halfway.
114
+ *
115
+ * A global install takes 30–60s (measured: 46s on Windows), and a Ctrl+C in the
116
+ * terminal reaches every process attached to it — npm killed mid-install can leave the
117
+ * package half-replaced and `jaitus` unable to start. So npm runs in its own process
118
+ * group (detached; windowsHide keeps Windows from opening a console for it) and carries
119
+ * on to the end whatever happens to this process. `onTick` gets the seconds elapsed.
120
+ */
121
+ export function installGlobal(v, { onTick = () => {}, onStart = () => {} } = {}) {
122
+ return new Promise((resolve) => {
123
+ const args = ['install', '-g', `jaitus@${v}`, '--no-audit', '--no-fund', '--loglevel=error'];
124
+ const opts = { stdio: ['ignore', 'ignore', 'pipe'], detached: true, windowsHide: true };
125
+ const child = process.platform === 'win32'
126
+ ? spawn(['npm', ...args].join(' '), { ...opts, shell: true })
127
+ : spawn('npm', args, opts);
128
+ if (child.pid) onStart(child.pid);
129
+ let stderr = '';
130
+ child.stderr?.on('data', (d) => { stderr = (stderr + d).slice(-4000); });
131
+ const t0 = Date.now();
132
+ const ticker = setInterval(() => onTick(Math.round((Date.now() - t0) / 1000)), 1000);
133
+ const finish = (code) => { clearInterval(ticker); resolve({ code, stderr: stderr.trim() }); };
134
+ child.on('error', () => finish(-1));
135
+ child.on('close', (code) => finish(code));
136
+ });
137
+ }
138
+
111
139
  /**
112
140
  * How this copy was installed: 'npm-global', 'npx', or 'other'.
113
141
  *
@@ -158,8 +186,10 @@ export function isNewer(a, b) {
158
186
  * Returns the child's exit code when it relaunched, or null to carry on in this process.
159
187
  */
160
188
  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') } = {}) {
189
+ 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 } = {}) {
163
193
  if (env.JAITUS_UPDATED_FROM) return null; // just relaunched: never loop
164
194
  if (!updateCheckEnabled(env) || env.JAITUS_NO_AUTOUPDATE || settings.autoUpdate === false) return null;
165
195
  if (!process.stdout.isTTY || !process.stdin.isTTY) return null; // scripts and pipes get a stable version
@@ -170,22 +200,58 @@ export async function autoUpdate({ argv = [], settings = {}, env = process.env,
170
200
  if (now - Number(failed.at) < DAY_MS) return null;
171
201
  } catch { /* no recent failure */ }
172
202
 
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
- ]);
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
+ const remember = (latest) => {
207
+ try { fs.mkdirSync(path.dirname(cacheFile), { recursive: true }); fs.writeFileSync(cacheFile, JSON.stringify({ checkedAt: Date.now(), latest: latest || null })); } catch { /* read-only home */ }
208
+ };
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 */ }
214
+ 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
+ ]));
221
+ }
178
222
  if (!latest || !isNewer(latest, current)) return null;
179
223
  if ((await method()) !== 'npm-global') return null; // the in-app notice covers these
180
224
 
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) {
225
+ const head = ` ${c.brand('Jaitus')} ${c.dim(`v${current} →`)} ${c.bold(`v${latest}`)}`;
226
+ write(`${head} ${c.dim('— updating…')}`);
227
+ // Ctrl+C now leaves npm to finish on its own (it is detached) — say so rather than
228
+ // leaving someone to wonder whether they broke the install. Runs before the bin's own
229
+ // SIGINT handler, which exits.
230
+ const onInterrupt = () => {
231
+ write(`\n ${c.dim(`The update keeps installing in the background. Start ${c.bold('jaitus')} again in a minute.`)}\n`);
232
+ };
233
+ process.prependListener('SIGINT', onInterrupt);
234
+ const doInstall = install || ((v) => installGlobal(v, {
235
+ onTick: (s) => write(`\r${head} ${c.dim(`— updating… ${s}s (this takes about a minute)`)}`),
236
+ // Left behind only if this process is gone before npm is — the next launch waits on it.
237
+ 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
+ }));
239
+ let result;
240
+ try { result = await doInstall(latest); } finally {
241
+ process.removeListener('SIGINT', onInterrupt);
242
+ try { fs.rmSync(runningFile, { force: true }); } catch { /* best effort */ }
243
+ }
244
+ write('\n');
245
+ if (result.code !== 0) {
184
246
  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')}`);
247
+ 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')}`);
249
+ 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))}`);
186
251
  return null;
187
252
  }
188
- log(` ${c.accent('✓')} ${c.dim('Updated — reopening')}`);
253
+ remember(latest);
254
+ log(` ${c.accent('✓')} ${c.dim(`Updated to v${latest} — reopening`)}`);
189
255
  return relaunch({ ...env, JAITUS_UPDATED_FROM: current });
190
256
  }
191
257
 
package/src/index.js CHANGED
@@ -9,7 +9,7 @@
9
9
  import path from 'node:path';
10
10
  import fs from 'node:fs';
11
11
  import { spawn, spawnSync } from 'node:child_process';
12
- import { createClient, NotSignedIn } from './api.js';
12
+ import { createClient, NotSignedIn, advertisedContextWindow } from './api.js';
13
13
  import { fromResponse, isJaitusError } from './errors.js';
14
14
  import { signedIn } from './session.js';
15
15
  import {
@@ -81,7 +81,7 @@ const FLAG_NAMES = ['--print', '-p', '--continue', '-c', '--resume', '-r', '--ou
81
81
  function parseArgs(argv) {
82
82
  const flags = {
83
83
  model: null, yes: false, cwd: process.cwd(), json: false, help: false, version: false,
84
- print: false, continue: false, resume: null, outputFormat: 'text', maxTurns: 40,
84
+ print: false, continue: false, resume: null, outputFormat: 'text', maxTurns: undefined,
85
85
  permissionMode: 'default', systemPrompt: '', appendSystemPrompt: '',
86
86
  };
87
87
  const rest = [];
@@ -97,7 +97,7 @@ function parseArgs(argv) {
97
97
  else if (a === '--continue' || a === '-c') flags.continue = true;
98
98
  else if (a === '--resume' || a === '-r') flags.resume = argv[++i];
99
99
  else if (a === '--output-format') flags.outputFormat = argv[++i] || 'text';
100
- else if (a === '--max-turns') flags.maxTurns = Math.max(1, Number(argv[++i]) || 40);
100
+ else if (a === '--max-turns') flags.maxTurns = Math.max(1, Number(argv[++i]) || 1000);
101
101
  else if (a === '--permission-mode') flags.permissionMode = argv[++i] || 'default';
102
102
  else if (a === '--dangerously-skip-permissions') flags.permissionMode = 'bypassPermissions';
103
103
  else if (a === '--system-prompt') flags.systemPrompt = argv[++i] || '';
@@ -300,10 +300,18 @@ function gitBranch(cwd) {
300
300
  }
301
301
  }
302
302
 
303
- // Used only to fill the context meter. The real ceiling is the model's, which the
304
- // gateway does not advertise; this is the conservative floor of what any model it
305
- // serves accepts, so the bar reads "getting full" before anything actually breaks.
306
- const CONTEXT_BUDGET = 180_000;
303
+ // How much conversation a session carries before it is compacted. The gateway works it
304
+ // out from the providers' real limits and the admin's cap, and advertises it on its
305
+ // model list; this floor is only used until that answer arrives, or if it never does.
306
+ const CONTEXT_FALLBACK = 128_000;
307
+ let CONTEXT_BUDGET = CONTEXT_FALLBACK;
308
+ async function loadContextBudget(client) {
309
+ try {
310
+ if (!advertisedContextWindow()) await client.models();
311
+ CONTEXT_BUDGET = advertisedContextWindow() || CONTEXT_FALLBACK;
312
+ } catch { /* keep the floor */ }
313
+ return CONTEXT_BUDGET;
314
+ }
307
315
 
308
316
  // Compact at this fraction of the budget. Low enough that the summarisation request
309
317
  // itself still fits comfortably, high enough that a normal session never sees it.
@@ -548,6 +556,7 @@ async function session({ flags }) {
548
556
  let [model, device] = await Promise.all([
549
557
  resolveModel(client, flags.model),
550
558
  probeDevice({}).catch(() => ({ vt: !!process.stdout.isTTY, sixel: false })),
559
+ loadContextBudget(client),
551
560
  ]);
552
561
  // Degrade on EVIDENCE, not on silence.
553
562
  //
@@ -769,9 +778,12 @@ async function session({ flags }) {
769
778
  const ctrl = new AbortController();
770
779
  activeAbort = ctrl;
771
780
  const turnStarted = Date.now();
781
+ // The new message counts toward the context from the moment it is sent.
782
+ try { showContext(); } catch { /* header keeps last value */ }
772
783
  try {
773
784
  const result = await runAgent({
774
785
  client, messages, model, cwd: flags.cwd, permissions, signal: ctrl.signal, tui, journal,
786
+ onContext: (n) => { lastContext = n; sessionState.lastContext = n; showContext(); },
775
787
  extraSystem: `${skillCatalog(skills)}\n\nCurrent intent: ${intentMode}. ${intentGuidance[intentMode]}${flags.appendSystemPrompt ? `\n\nAdditional instructions:\n${flags.appendSystemPrompt}` : ''}`,
776
788
  systemOverride: flags.systemPrompt || null,
777
789
  });
@@ -0,0 +1,41 @@
1
+ // ─── An update still installing ─────────────────────────────────────────────────
2
+ //
3
+ // Loaded by bin/jaitus.js BEFORE the app, and so only from Node's own modules.
4
+ //
5
+ // A launch that is closed with Ctrl+C during its auto-update leaves npm to finish on its
6
+ // own (commands/update.js runs it detached so it cannot be cut off halfway). npm writes
7
+ // the new package.json before it has finished unpacking the dependencies, so a launch in
8
+ // those seconds would load a half-installed copy — measured: ERR_UNSUPPORTED_DIR_IMPORT
9
+ // from ink, a crash before any of our code runs. So the launcher waits for that npm to
10
+ // exit (up to 3 minutes), then loads the app, which is by then the new version.
11
+
12
+ import fs from 'node:fs';
13
+ import os from 'node:os';
14
+ import path from 'node:path';
15
+
16
+ export const RUNNING_FILE = path.join(os.homedir(), '.jaitus', 'update-running.json');
17
+
18
+ export function pidAlive(pid) {
19
+ try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
20
+ }
21
+
22
+ export async function waitForPendingUpdate({ file = RUNNING_FILE, alive = pidAlive, write = (s) => process.stderr.write(s), maxMs = 180e3, pollMs = 500 } = {}) {
23
+ let running;
24
+ try { running = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return false; }
25
+ // Stale (a crashed machine, a reused pid long after): ignore it and clear it.
26
+ if (!running?.pid || !alive(running.pid) || Date.now() - Number(running.at) > 10 * 60e3) {
27
+ try { fs.rmSync(file, { force: true }); } catch { /* best effort */ }
28
+ return false;
29
+ }
30
+ const tty = process.stderr.isTTY;
31
+ const head = ` Jaitus — finishing the update to v${running.version}…`;
32
+ write(head);
33
+ const t0 = Date.now();
34
+ while (alive(running.pid) && Date.now() - t0 < maxMs) {
35
+ await new Promise((r) => setTimeout(r, pollMs));
36
+ if (tty) write(`\r${head} ${Math.round((Date.now() - t0) / 1000)}s`);
37
+ }
38
+ write('\n');
39
+ try { fs.rmSync(file, { force: true }); } catch { /* best effort */ }
40
+ return true;
41
+ }
@@ -0,0 +1,68 @@
1
+ // ─── Reading the system clipboard ───────────────────────────────────────────────
2
+ //
3
+ // The app takes the mouse, so a terminal's own right-click paste never happens — the
4
+ // click is reported to the app instead, and it used to be thrown away: "paste does not
5
+ // work". Ctrl+V is the terminal's in some terminals and the app's in others. So the app
6
+ // reads the clipboard itself for right-click and Ctrl+V, and for Alt+V (an image, which
7
+ // no terminal can type in).
8
+ //
9
+ // Asynchronous on purpose: PowerShell takes a few hundred milliseconds to start, and a
10
+ // synchronous read froze the whole interface for that long.
11
+
12
+ import { spawn } from 'node:child_process';
13
+
14
+ function capture(cmd, args, { timeout = 4000, binary = false } = {}) {
15
+ return new Promise((resolve) => {
16
+ let child;
17
+ try { child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }); } catch { resolve(null); return; }
18
+ const chunks = [];
19
+ const timer = setTimeout(() => { try { child.kill(); } catch { /* gone */ } resolve(null); }, timeout);
20
+ timer.unref?.();
21
+ child.stdout.on('data', (d) => chunks.push(d));
22
+ child.on('error', () => { clearTimeout(timer); resolve(null); });
23
+ child.on('close', (code) => {
24
+ clearTimeout(timer);
25
+ if (code !== 0) return resolve(null);
26
+ const buf = Buffer.concat(chunks);
27
+ resolve(binary ? buf : buf.toString('utf8'));
28
+ });
29
+ });
30
+ }
31
+
32
+ /** Plain text on the clipboard, or null (empty, not text, or no clipboard tool). */
33
+ export async function readClipboardText({ platform = process.platform, run = capture } = {}) {
34
+ let text = null;
35
+ if (platform === 'win32') {
36
+ // UTF-8 out, or anything outside the console code page (quotes, dashes, non-Latin
37
+ // text) arrives as '?'. -Raw keeps line breaks as they were copied.
38
+ text = await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command',
39
+ '[Console]::OutputEncoding=[Text.Encoding]::UTF8; $t = Get-Clipboard -Raw; if ($null -ne $t) { [Console]::Out.Write($t) }']);
40
+ } else if (platform === 'darwin') {
41
+ text = await run('pbpaste', []);
42
+ } else {
43
+ for (const [cmd, args] of [['wl-paste', ['--no-newline']], ['xclip', ['-selection', 'clipboard', '-o']], ['xsel', ['--clipboard', '--output']]]) {
44
+ text = await run(cmd, args);
45
+ if (text != null) break;
46
+ }
47
+ }
48
+ return text ? text : null;
49
+ }
50
+
51
+ /** PNG bytes of an image on the clipboard, or null. */
52
+ export async function readClipboardImage({ platform = process.platform, run = capture } = {}) {
53
+ if (platform === 'win32') {
54
+ const b64 = await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command',
55
+ 'Add-Type -AssemblyName System.Windows.Forms,System.Drawing; $i = [Windows.Forms.Clipboard]::GetImage(); if (!$i) { exit 1 }; $m = New-Object IO.MemoryStream; $i.Save($m, [Drawing.Imaging.ImageFormat]::Png); [Console]::Out.Write([Convert]::ToBase64String($m.ToArray()))'],
56
+ { timeout: 8000 });
57
+ return b64 && b64.trim() ? Buffer.from(b64.trim(), 'base64') : null;
58
+ }
59
+ if (platform === 'darwin') {
60
+ const bytes = await run('pngpaste', ['-'], { timeout: 8000, binary: true });
61
+ return bytes?.length ? bytes : null;
62
+ }
63
+ for (const [cmd, args] of [['wl-paste', ['--type', 'image/png']], ['xclip', ['-selection', 'clipboard', '-t', 'image/png', '-o']]]) {
64
+ const bytes = await run(cmd, args, { timeout: 8000, binary: true });
65
+ if (bytes?.length) return bytes;
66
+ }
67
+ return null;
68
+ }
@@ -194,8 +194,12 @@ export function Reveal({ children, rows, duration = 140 }) {
194
194
  /** Rows of spans → Ink text. The single place span objects become pixels. */
195
195
  export function Rows({ rows, maxRows }) {
196
196
  const visible = maxRows && rows.length > maxRows ? rows.slice(rows.length - maxRows) : rows;
197
- return h(Box, { flexDirection: 'column' }, visible.map((row, index) =>
198
- h(Text, { key: index, wrap: 'truncate-end' }, row.length
197
+ // flexShrink 0, on the column and on every row: inside a box with a fixed height,
198
+ // Yoga otherwise SHRINKS rows to fit, and shrunk rows in a terminal are drawn on top
199
+ // of each other — two lines of a reply painted onto one ("while it arrives.is a
200
+ // reply…", measured). Rows that do not fit must be clipped by the parent, never merged.
201
+ return h(Box, { flexDirection: 'column', flexShrink: 0 }, visible.map((row, index) =>
202
+ h(Text, { key: index, wrap: 'truncate-end', flexShrink: 0 }, row.length
199
203
  ? row.map((span, i) => h(Text, {
200
204
  key: i,
201
205
  color: span.color,