jaitus 0.3.4 → 0.3.5

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/src/index.js CHANGED
@@ -16,16 +16,19 @@ import {
16
16
  loadCredentials, clearCredentials, loadSettings, saveSettings,
17
17
  endpoints, version, HOME, HISTORY,
18
18
  } from './config.js';
19
- import { runAgent } from './agent/loop.js';
19
+ import { runAgent, repairToolPairs } from './agent/loop.js';
20
20
  import { createPermissions } from './agent/permissions.js';
21
- import { forgetIgnoreCache, imageBlock } from './agent/tools.js';
21
+ import { DEFAULT_DENY } from './agent/rules.js';
22
+ import { forgetIgnoreCache, imageBlock, IMAGE_MIME } from './agent/tools.js';
23
+ import { shrinkImageFile } from './tui/clipboard.js';
24
+ import { THEME_NAMES } from './tui/theme.js';
22
25
  import { loadContext, CONTEXT_FILE } from './project.js';
23
26
  import { discoverSkills, skillCatalog } from './skills.js';
24
27
  import { runHook, configuredHookEvents } from './hooks.js';
25
- import { newSession, saveSession, loadSession, latestSession, listSessions, sessionSummary, replaySession } from './sessions.js';
28
+ import { newSession, saveSession, loadSession, latestSession, listSessions, sessionSummary, replaySession, sameDir } from './sessions.js';
26
29
  import { createJournal, displayPath } from './rewind.js';
27
30
  import { createLedger } from './ledger.js';
28
- import { listMcpServers, addMcpServer, removeMcpServer } from './mcp.js';
31
+ import { listMcpServers, addMcpServer, removeMcpServer, untrustedServers, trustServer, discoverMcpTools, mcpProblems, allServers } from './mcp.js';
29
32
  import { banner, wordmark, renderHomeLogo, startupTip } from './brand.js';
30
33
  import {
31
34
  c, out, err, ok, fail, info, warn, table, present,
@@ -78,6 +81,12 @@ const OPTIONS = [
78
81
 
79
82
  const FLAG_NAMES = ['--print', '-p', '--continue', '-c', '--resume', '-r', '--output-format', '--max-turns', '--permission-mode', '--dangerously-skip-permissions', '--system-prompt', '--append-system-prompt', '--model', '--cwd', '--yes', '-y', '--json', '--no-color', '--no-banner', '--help', '-h', '--version', '-v', '--force', '--ascii'];
80
83
 
84
+ /** `Plan`, `acceptedits`, `bypass` → the real mode name; anything else → null. */
85
+ function normaliseMode(value) {
86
+ const v = String(value || '').trim().toLowerCase().replace(/[-_\s]/g, '');
87
+ return { default: 'default', ask: 'default', plan: 'plan', acceptedits: 'acceptEdits', autoedit: 'acceptEdits', bypass: 'bypassPermissions', bypasspermissions: 'bypassPermissions', auto: 'bypassPermissions' }[v] || null;
88
+ }
89
+
81
90
  function parseArgs(argv) {
82
91
  const flags = {
83
92
  model: null, yes: false, cwd: process.cwd(), json: false, help: false, version: false,
@@ -87,19 +96,33 @@ function parseArgs(argv) {
87
96
  const rest = [];
88
97
  const passthrough = [];
89
98
  let literal = false;
99
+ // After a subcommand, its arguments are its own, in order: `jaitus mcp add fs npx -y
100
+ // @scope/server` used to lose the -y (read as Jaitus's --yes), and `--port 3000` was
101
+ // moved to the end. Only the few flags that mean the same thing everywhere are still
102
+ // read as Jaitus flags there.
103
+ let sub = false;
104
+ const SUB_GLOBAL = new Set(['--json', '--help', '--cwd', '--no-color', '--ascii', '--no-banner']);
90
105
 
91
106
  for (let i = 0; i < argv.length; i++) {
92
107
  const a = argv[i];
93
108
  if (literal) { rest.push(a); continue; }
109
+ if (!rest.length && !a.startsWith('-') && Object.prototype.hasOwnProperty.call(commands, a)) { sub = true; rest.push(a); continue; }
110
+ if (sub && !SUB_GLOBAL.has(a)) {
111
+ if (a === '--') { literal = true; continue; }
112
+ if (a === '--yes' || a === '-y') flags.yes = true;
113
+ rest.push(a);
114
+ continue;
115
+ }
94
116
 
95
117
  if (a === '--') { literal = true; continue; }
96
118
  else if (a === '--print' || a === '-p') flags.print = true;
97
119
  else if (a === '--continue' || a === '-c') flags.continue = true;
98
120
  else if (a === '--resume' || a === '-r') flags.resume = argv[++i];
99
121
  else if (a === '--output-format') flags.outputFormat = argv[++i] || 'text';
100
- else if (a === '--max-turns') flags.maxTurns = Math.max(1, Number(argv[++i]) || 1000);
101
- else if (a === '--permission-mode') flags.permissionMode = argv[++i] || 'default';
102
- else if (a === '--dangerously-skip-permissions') flags.permissionMode = 'bypassPermissions';
122
+ // Validated below: `--max-turns 0` or `abc` silently became the 1000-step maximum.
123
+ else if (a === '--max-turns') { flags.maxTurnsRaw = argv[++i]; flags.maxTurns = Number(flags.maxTurnsRaw); }
124
+ else if (a === '--permission-mode') { flags.permissionMode = normaliseMode(argv[++i]); flags.permissionModeExplicit = true; }
125
+ else if (a === '--dangerously-skip-permissions') { flags.permissionMode = 'bypassPermissions'; flags.permissionModeExplicit = true; }
103
126
  else if (a === '--system-prompt') flags.systemPrompt = argv[++i] || '';
104
127
  else if (a === '--append-system-prompt') flags.appendSystemPrompt = argv[++i] || '';
105
128
  else if (a === '--model') flags.model = argv[++i];
@@ -174,11 +197,27 @@ function commandHelp(name) {
174
197
  // ─── Shared plumbing ────────────────────────────────────────────────────────────
175
198
 
176
199
  /** Anything piped in becomes context for the task. */
177
- async function readStdin() {
200
+ async function readStdin({ patient = false } = {}) {
178
201
  if (process.stdin.isTTY) return '';
179
- const chunks = [];
180
- for await (const chunk of process.stdin) chunks.push(chunk);
181
- return Buffer.concat(chunks).toString('utf8').trim();
202
+ if (patient) {
203
+ // A task was given: wait for the whole pipe (`npm test 2>&1 | jaitus -p "why"` can
204
+ // take seconds before its first byte).
205
+ const chunks = [];
206
+ for await (const chunk of process.stdin) chunks.push(chunk);
207
+ return Buffer.concat(chunks).toString('utf8').trim();
208
+ }
209
+ // Git Bash (mintty) and some IDE terminals give a pipe that never closes: waiting for
210
+ // its end hung `jaitus` forever. Piped input starts at once; with nothing in the first
211
+ // moment there is nothing piped.
212
+ return new Promise((resolve) => {
213
+ const chunks = [];
214
+ let got = false;
215
+ const done = () => { process.stdin.removeAllListeners('data'); process.stdin.pause(); resolve(Buffer.concat(chunks).toString('utf8').trim()); };
216
+ const idle = setTimeout(() => { if (!got) done(); }, 400);
217
+ process.stdin.on('data', (c) => { got = true; chunks.push(c); });
218
+ process.stdin.on('end', () => { clearTimeout(idle); done(); });
219
+ process.stdin.on('error', () => { clearTimeout(idle); done(); });
220
+ });
182
221
  }
183
222
 
184
223
  class NoModel extends Error {}
@@ -219,6 +258,10 @@ function estimateTokens(messages) {
219
258
 
220
259
  /** Clipboard write that degrades honestly: returns false when no path exists. */
221
260
  async function copyToClipboard(text) {
261
+ // The terminal tests' stand-in clipboard (see tui/clipboard.js): never the real one.
262
+ if (process.env.JAITUS_CLIPBOARD_DIR) {
263
+ try { fs.writeFileSync(path.join(process.env.JAITUS_CLIPBOARD_DIR, 'copied.txt'), text); return true; } catch { return false; }
264
+ }
222
265
  const { spawn } = await import('node:child_process');
223
266
  const { promisify } = await import('node:util');
224
267
  const platforms = process.platform === 'win32' ? [['clip']]
@@ -239,11 +282,13 @@ async function copyToClipboard(text) {
239
282
  return false;
240
283
  }
241
284
 
242
- function requireAuth() {
285
+ function requireAuth({ quiet = false } = {}) {
243
286
  try {
244
287
  return createClient();
245
288
  } catch (e) {
246
289
  if (e instanceof NotSignedIn) {
290
+ // quiet: the caller reports it in its own format (--output-format json).
291
+ if (quiet) return null;
247
292
  fail(e.message);
248
293
  info(`Run ${c.bold('jaitus login')} — it opens your browser, no key to copy.`);
249
294
  process.exit(1);
@@ -325,17 +370,50 @@ const MENTION_MAX_BYTES = 48_000;
325
370
  * One summarisation call. Shared by /compact and auto-compaction so the two cannot
326
371
  * drift into producing different kinds of summary from the same conversation.
327
372
  */
373
+ /**
374
+ * The conversation as plain text, for summarising. Sending the raw history broke in
375
+ * three ways: every image was sent again (past the gateway's size limit once there were
376
+ * a few), tool calls went without a tools list (which some providers reject), and a long
377
+ * session was larger than the window being compacted. Tool calls and results become
378
+ * short lines, images a note, and the middle of a very long session is trimmed.
379
+ */
380
+ function transcriptForSummary(messages, maxChars = 360_000) {
381
+ const clip = (s, n) => (s.length > n ? `${s.slice(0, n)}…` : s);
382
+ const lines = [];
383
+ for (const m of messages) {
384
+ const who = m.role === 'assistant' ? 'ASSISTANT' : 'USER';
385
+ if (typeof m.content === 'string') { lines.push(`${who}: ${m.content}`); continue; }
386
+ for (const b of Array.isArray(m.content) ? m.content : []) {
387
+ if (b?.type === 'text' && b.text) lines.push(`${who}: ${b.text}`);
388
+ else if (b?.type === 'tool_use') lines.push(`${who} ran ${b.name}: ${clip(JSON.stringify(b.input ?? {}), 400)}`);
389
+ else if (b?.type === 'tool_result') {
390
+ const t = typeof b.content === 'string' ? b.content : Array.isArray(b.content) ? b.content.map((c) => c?.text || '').join('\n') : '';
391
+ lines.push(`RESULT${b.is_error ? ' (error)' : ''}: ${clip(t, 1500)}`);
392
+ } else if (b?.type === 'image') lines.push(`${who}: [an image]`);
393
+ }
394
+ }
395
+ let text = lines.join('\n');
396
+ if (text.length > maxChars) {
397
+ const head = text.slice(0, Math.floor(maxChars * 0.25));
398
+ const tail = text.slice(-Math.floor(maxChars * 0.7));
399
+ text = `${head}\n\n[… the middle of the session is left out …]\n\n${tail}`;
400
+ }
401
+ return text;
402
+ }
403
+
328
404
  async function summarise(client, model, messages, focus) {
329
405
  const res = await client.messages({
330
406
  model,
331
- max_tokens: 2048,
407
+ // Room for the model to reason before it writes; at 2048 the reasoning could use
408
+ // it all and the summary came back empty.
409
+ max_tokens: 6000,
332
410
  messages: [
333
- ...messages,
334
411
  {
335
412
  role: 'user',
336
413
  content: 'Summarize this conversation compactly for a successor agent: what was asked, '
337
414
  + 'what was done (files changed, commands run, outcomes), what remains. Be specific and terse.'
338
- + (focus ? ` Focus on: ${focus}.` : ''),
415
+ + (focus ? ` Focus on: ${focus}.` : '')
416
+ + `\n\n<conversation>\n${transcriptForSummary(messages)}\n</conversation>`,
339
417
  },
340
418
  ],
341
419
  stream: false,
@@ -357,6 +435,9 @@ async function summarise(client, model, messages, focus) {
357
435
  function expandMentions(input, cwd) {
358
436
  const blocks = [];
359
437
  const notes = [];
438
+ // Image files are attached as images by the caller (async: they may need shrinking).
439
+ // Read as text, a PNG used to go into the prompt as a page of binary garbage.
440
+ const images = [];
360
441
  const seen = new Set();
361
442
  for (const match of String(input).matchAll(/(?:^|\s)@([^\s@]+)/g)) {
362
443
  const ref = match[1].replace(/[.,;:)]+$/, '');
@@ -375,41 +456,95 @@ function expandMentions(input, cwd) {
375
456
  notes.push(`@${ref} — ${entries.length} entries attached`);
376
457
  continue;
377
458
  }
459
+ if (IMAGE_MIME[path.extname(full).toLowerCase()]) {
460
+ images.push(full);
461
+ notes.push(`@${ref} — image attached`);
462
+ continue;
463
+ }
378
464
  if (stat.size > MENTION_MAX_BYTES) {
379
465
  notes.push(`@${ref} is ${Math.round(stat.size / 1024)} kB — too large to attach; the agent can read it if it needs to.`);
380
466
  continue;
381
467
  }
382
- const text = fs.readFileSync(full, 'utf8');
468
+ const buf = fs.readFileSync(full);
469
+ // Any other binary (a zip, a PDF, an .exe) is not text either: say so, send nothing.
470
+ if (buf.subarray(0, 8000).includes(0)) {
471
+ notes.push(`@${ref} is a binary file — not attached. The agent can inspect it with a command if it needs to.`);
472
+ continue;
473
+ }
474
+ const text = buf.toString('utf8');
383
475
  blocks.push({ type: 'text', text: `<mentioned path="${ref}">\n${text}\n</mentioned>` });
384
476
  notes.push(`@${ref} — ${text.split('\n').length} lines attached`);
385
477
  } catch {
386
478
  notes.push(`@${ref} could not be read — not attached.`);
387
479
  }
388
480
  }
389
- return { blocks, notes };
481
+ return { blocks, notes, images };
390
482
  }
391
483
 
392
484
  /** Saved allow/deny rules for one project directory. */
393
485
  function projectRules(cwd) {
394
- const saved = loadSettings().permissionRules?.[cwd] || {};
486
+ const all = loadSettings().permissionRules || {};
487
+ // Keyed by folder, and on Windows a folder has many spellings (`c:\app`, `C:\App`):
488
+ // typed differently, the saved "always allow" rules were simply not found.
489
+ const key = Object.keys(all).find((k) => sameDir(k, cwd));
490
+ const saved = (key && all[key]) || {};
395
491
  return { allow: Array.isArray(saved.allow) ? saved.allow : [], deny: Array.isArray(saved.deny) ? saved.deny : [] };
396
492
  }
397
493
 
494
+ /** The key rules are saved under: the existing spelling when there is one. */
495
+ function rulesKey(cwd) {
496
+ return Object.keys(loadSettings().permissionRules || {}).find((k) => sameDir(k, cwd)) || path.resolve(cwd);
497
+ }
498
+
398
499
  /** 18400 → "18.4k". Header space is scarce and the exact digit never mattered. */
399
500
  const format = (n) => (n >= 1000 ? `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}k` : String(n));
400
501
 
502
+ /**
503
+ * A task from the command line: run it, print the answer, exit.
504
+ *
505
+ * Nobody is there to answer a prompt, so nothing prompts: a write or command that needs
506
+ * permission is refused with the flag that would allow it (it used to wait in raw mode
507
+ * for a key nobody saw, and write the question into --output-format json). A run that
508
+ * did not finish exits 1 and says why on stderr — it used to exit 0 whatever happened.
509
+ * --continue / --resume pick up a saved session and the result is saved back.
510
+ */
401
511
  async function oneShot(task, { flags }) {
402
- const client = requireAuth();
403
- const model = await resolveModel(client, flags.model);
404
- const messages = [{ role: 'user', content: task }];
405
- const permissions = createPermissions({ autoApprove: flags.yes, mode: flags.permissionMode });
406
- const format = ['text', 'json', 'stream-json'].includes(flags.outputFormat) ? flags.outputFormat : 'text';
512
+ const format = flags.outputFormat;
407
513
  let answer = '';
514
+ const failJson = (message, status = 500, code = 'internal') => {
515
+ if (format !== 'text') out(JSON.stringify({ type: 'error', error: message, code, status }));
516
+ else fail(message);
517
+ return 1;
518
+ };
519
+ let client;
520
+ let model;
521
+ try {
522
+ client = requireAuth({ quiet: format !== 'text' });
523
+ model = await resolveModel(client, flags.model);
524
+ } catch (e) {
525
+ // Sign-in and model-list failures used to escape before the try below, as plain
526
+ // text, whatever --output-format said.
527
+ if (format !== 'text') return failJson(e.message || String(e), e.status || 401, e.code || 'auth_required');
528
+ return reportAgentError(e);
529
+ }
530
+ if (!client) return failJson('Not signed in. Run `jaitus login`.', 401, 'auth_required');
531
+
532
+ const loaded = flags.resume ? loadSession(flags.resume) : flags.continue ? latestSession({ cwd: flags.cwd }) : null;
533
+ if ((flags.resume || flags.continue) && !loaded) return failJson(flags.resume ? `No saved session ${flags.resume}. \`jaitus sessions\` lists them.` : 'No saved session in this folder to continue.', 404, 'not_found');
534
+ const session = loaded || newSession({ cwd: flags.cwd, model });
535
+ const messages = Array.isArray(session.messages) ? session.messages : [];
536
+ messages.push({ role: 'user', content: task });
537
+
538
+ const explicitPlan = flags.permissionModeExplicit && flags.permissionMode === 'plan';
539
+ const permissions = createPermissions({
540
+ autoApprove: flags.yes && !explicitPlan, mode: flags.permissionMode,
541
+ interactive: false, ...projectRules(flags.cwd),
542
+ });
408
543
 
409
544
  try {
410
545
  const result = await runAgent({
411
546
  client, messages, model, cwd: flags.cwd, permissions,
412
- maxIterations: flags.maxTurns,
547
+ ...(flags.maxTurns ? { maxIterations: flags.maxTurns } : {}),
413
548
  silent: true,
414
549
  systemOverride: flags.systemPrompt || null,
415
550
  extraSystem: flags.appendSystemPrompt ? `\n\nAdditional instructions:\n${flags.appendSystemPrompt}` : '',
@@ -418,17 +553,32 @@ async function oneShot(task, { flags }) {
418
553
  if (format === 'stream-json') out(JSON.stringify({ type: 'content_block_delta', delta: { text } }));
419
554
  },
420
555
  });
556
+ repairToolPairs(messages);
557
+ session.messages = messages;
558
+ session.model = model;
559
+ try { saveSession(session); } catch { /* the answer still stands */ }
560
+
561
+ const unfinished = {
562
+ max_iterations: `Stopped at the step limit (${flags.maxTurns || 'the safety limit'}) before finishing.`,
563
+ stuck: 'Stopped: it repeated the same action without making progress.',
564
+ plan_blocked: 'Plan mode is read-only; nothing was written or run.',
565
+ cut: 'The answer was cut off before it finished.',
566
+ interrupted: 'Interrupted.',
567
+ }[result.stopReason];
568
+ const deniedNote = result.denied ? `${result.denied} action${result.denied === 1 ? '' : 's'} needed permission and did not run — re-run with --yes, or --permission-mode acceptEdits for file edits only.` : '';
421
569
  if (format === 'json') {
422
- out(JSON.stringify({ type: 'result', result: answer, model, stop_reason: result.stopReason, usage: result }));
570
+ out(JSON.stringify({ type: 'result', result: answer, model, stop_reason: result.stopReason, finished: !unfinished, session_id: session.id, denied: result.denied || 0, usage: result }));
423
571
  } else if (format === 'stream-json') {
424
- out(JSON.stringify({ type: 'message_stop', stop_reason: result.stopReason, usage: result }));
572
+ out(JSON.stringify({ type: 'message_stop', stop_reason: result.stopReason, finished: !unfinished, session_id: session.id, denied: result.denied || 0, usage: result }));
425
573
  } else {
426
574
  out(answer);
427
575
  }
428
- return 0;
576
+ if (unfinished) process.stderr.write(`${unfinished}\n`);
577
+ if (deniedNote) process.stderr.write(`${deniedNote}\n`);
578
+ return unfinished || result.denied ? 1 : 0;
429
579
  } catch (e) {
430
- if (format !== 'text') out(JSON.stringify({ type: 'error', error: e.message, status: e.status || 500 }));
431
- if (format !== 'text') return 1;
580
+ try { repairToolPairs(messages); session.messages = messages; saveSession(session); } catch { /* best effort */ }
581
+ if (format !== 'text') return failJson(e.message, e.status || 500, e.code || 'internal');
432
582
  return reportAgentError(e);
433
583
  }
434
584
  }
@@ -448,7 +598,7 @@ const SLASH = {
448
598
  '/compact [focus]': { summary: 'summarize the conversation to free context' },
449
599
  '/export [file]': { summary: 'write the conversation to a file (txt/md/json)' },
450
600
  '/copy': { summary: 'copy the last response to the clipboard' },
451
- '/diff': { summary: 'what changed on disk since the session started' },
601
+ '/diff': { summary: 'uncommitted changes, staged or not, and new files' },
452
602
  '/tasks': { summary: 'review tool activity from this session' },
453
603
  '/image <path>': { summary: 'attach an image (png/jpg/gif/webp) the model will see with your next message' },
454
604
  '/memory [text]': { summary: 'show or append to JAITUS.md (# does this inline)' },
@@ -592,8 +742,11 @@ async function session({ flags }) {
592
742
  sessionState.model = model;
593
743
  const creds = loadCredentials();
594
744
  let tui = null;
745
+ // An explicit plan mode beats -y: `-y --permission-mode plan` asked for read-only and
746
+ // used to run every write and command.
747
+ const explicitPlan = flags.permissionModeExplicit && flags.permissionMode === 'plan';
595
748
  const permissions = createPermissions({
596
- autoApprove: flags.yes,
749
+ autoApprove: flags.yes && !explicitPlan,
597
750
  mode: flags.permissionMode,
598
751
  // Saved rules are per project, keyed by directory, because "always allow npm test"
599
752
  // in one repo says nothing about what is safe in another.
@@ -602,7 +755,7 @@ async function session({ flags }) {
602
755
  const settings = loadSettings();
603
756
  saveSettings({
604
757
  ...settings,
605
- permissionRules: { ...(settings.permissionRules || {}), [flags.cwd]: { allow, deny } },
758
+ permissionRules: { ...(settings.permissionRules || {}), [rulesKey(flags.cwd)]: { allow, deny } },
606
759
  });
607
760
  },
608
761
  onModeChange: (m) => {
@@ -610,7 +763,12 @@ async function session({ flags }) {
610
763
  tui?.setMeta({ mode: m });
611
764
  },
612
765
  });
613
- if (loadSettings().lastMode && !flags.yes) permissions.setMode(loadSettings().lastMode);
766
+ // The last mode comes back only when nothing was asked for on the command line — the
767
+ // flag used to be overridden by it, so after one Shift+Tab `--permission-mode plan`
768
+ // started a session that edited files without asking. Plan (often set by /ask or
769
+ // /plan, not chosen) and bypass never carry into a new session on their own.
770
+ const lastMode = loadSettings().lastMode;
771
+ if (lastMode && !flags.yes && !flags.permissionModeExplicit && ['default', 'acceptEdits'].includes(lastMode)) permissions.setMode(lastMode);
614
772
  // Starting straight into bypass gets one explicit confirmation (§11) when a
615
773
  // human is present; headless runs just get the warning on stderr.
616
774
  if (permissions.mode === 'bypassPermissions' && !flags.yes) {
@@ -681,7 +839,7 @@ async function session({ flags }) {
681
839
  // ── The fullscreen interface ──
682
840
  // The alternate-screen app owns a fixed composer and a scrolling transcript. out()
683
841
  // routes transcript events through it so command output cannot disturb the composer.
684
- tui = await createTui({
842
+ const tuiOptions = {
685
843
  // Names AND summaries: the composer menu and the palette both show what a command
686
844
  // does beside its name, which is the difference between a list you scan and a list
687
845
  // you have to already know.
@@ -689,7 +847,7 @@ async function session({ flags }) {
689
847
  ...Object.entries(SLASH)
690
848
  .filter(([, value]) => !value.hidden)
691
849
  .map(([key, value]) => ({ name: key.split(' ')[0], summary: value.summary })),
692
- ...Object.entries(skills).map(([name, skill]) => ({ name: `/${name}`, summary: skill.summary || 'project workflow' })),
850
+ ...Object.entries(skills).map(([name, skill]) => ({ name: `/${name}`, summary: skill.description || skill.summary || 'project workflow' })),
693
851
  ],
694
852
  fileProvider: (frag) => completeFiles(frag),
695
853
  settings: loadSettings(),
@@ -717,7 +875,8 @@ async function session({ flags }) {
717
875
  refreshStatusLine('mode');
718
876
  return m;
719
877
  },
720
- });
878
+ };
879
+ tui = await createTui(tuiOptions);
721
880
  setTuiSink(tui);
722
881
  activeTui = tui;
723
882
 
@@ -771,13 +930,59 @@ async function session({ flags }) {
771
930
  // Esc/Ctrl+C while busy aborts the current run; the loop below notices via the
772
931
  // rejected signal and reports the interruption.
773
932
  let activeAbort = null;
933
+ let lastRunInterrupted = false;
774
934
  tui.onEscape(() => { if (activeAbort) activeAbort.abort(); });
775
935
 
936
+ /**
937
+ * A side run — /review, /research, /explain, /btw, /agent, /test --fix, /commit — in
938
+ * the app. They used to call the agent with no screen and no stop signal: the answer
939
+ * was written straight to the terminal over the app and wiped by the next frame (never
940
+ * in the transcript, never copyable), and Esc could not stop them. They also got fresh
941
+ * permissions without the user's deny rules. Now: the app's transcript, Esc stops it,
942
+ * and the session's rules in the mode asked for.
943
+ */
944
+ /** Run fn(signal) so that Esc stops it — for commands that run something long (/test). */
945
+ const abortable = async (fn) => {
946
+ const ctrl = new AbortController();
947
+ const outer = activeAbort;
948
+ activeAbort = ctrl;
949
+ try { return await fn(ctrl.signal); } finally { activeAbort = outer; }
950
+ };
951
+
952
+ const runSide = async ({ messages: sideMessages, mode = 'plan', extraSystem = '', onText = null, maxIterations, allow = [] } = {}) => {
953
+ const ctrl = new AbortController();
954
+ const outer = activeAbort;
955
+ activeAbort = ctrl;
956
+ try {
957
+ const result = await runAgent({
958
+ client, messages: sideMessages, model, cwd: flags.cwd,
959
+ permissions: permissions.fork({ mode, allow }), signal: ctrl.signal, tui, journal,
960
+ extraSystem, onText, ...(maxIterations ? { maxIterations } : {}),
961
+ });
962
+ if (result?.stopReason === 'interrupted') lastRunInterrupted = true;
963
+ if (result && !result.__error) {
964
+ spent.input += result.input || 0;
965
+ spent.cached += result.cached || 0;
966
+ spent.output += result.output || 0;
967
+ spent.tools += result.tools || 0;
968
+ try { tui?.setSpent?.(spent); } catch { /* header keeps last value */ }
969
+ }
970
+ return { ...result, messages: sideMessages };
971
+ } finally {
972
+ activeAbort = outer;
973
+ }
974
+ };
975
+
776
976
  /** One agent run with abort wiring, error accounting, and queue draining after. */
777
977
  const runManaged = async (messages) => {
778
978
  const ctrl = new AbortController();
779
979
  activeAbort = ctrl;
780
980
  const turnStarted = Date.now();
981
+ // Where this run started: on a failure, only a prompt that got NO answer at all is
982
+ // removed. Popping "the last user message" after a few tool steps removed the tool
983
+ // RESULTS instead, leaving a tool call with no result — which the provider rejects,
984
+ // so every later message in the session failed too.
985
+ const startLength = messages.length;
781
986
  // The new message counts toward the context from the moment it is sent.
782
987
  try { showContext(); } catch { /* header keeps last value */ }
783
988
  try {
@@ -796,6 +1001,7 @@ async function session({ flags }) {
796
1001
  if (result.context) { lastContext = result.context; sessionState.lastContext = lastContext; }
797
1002
  }
798
1003
  if (result?.stopReason === 'interrupted') {
1004
+ lastRunInterrupted = true;
799
1005
  ledger.note('interrupted', 'The user interrupted the previous turn with Esc before it finished. Whatever it was doing did not complete.');
800
1006
  // Keep the partial assistant turn out of history if it never completed.
801
1007
  const last = messages[messages.length - 1];
@@ -819,9 +1025,17 @@ async function session({ flags }) {
819
1025
  // conversation the API will reject — but the model is told it happened. Without
820
1026
  // that, its next turn begins as though nothing went wrong and it re-attempts the
821
1027
  // thing that just failed.
822
- ledger.note('turn-failed', `A request to the gateway failed${e.status ? ` with HTTP ${e.status}` : ''}: ${e.message}. That turn was discarded and the user's message was not answered.`);
823
- if (messages[messages.length - 1]?.role === 'user') messages.pop();
1028
+ if (ctrl.signal.aborted) lastRunInterrupted = true;
1029
+ if (messages.length === startLength && messages[messages.length - 1]?.role === 'user') {
1030
+ messages.pop();
1031
+ ledger.note('turn-failed', `A request to the gateway failed${e.status ? ` with HTTP ${e.status}` : ''}: ${e.message}. That turn was discarded and the user's message was not answered.`);
1032
+ } else {
1033
+ ledger.note('turn-failed', `A request to the gateway failed${e.status ? ` with HTTP ${e.status}` : ''} partway through the task: ${e.message}. The steps before it are kept; it did not finish.`);
1034
+ }
824
1035
  } finally {
1036
+ // Whatever ended the run, the history leaves here well-formed: every tool call
1037
+ // has a result.
1038
+ repairToolPairs(messages);
825
1039
  activeAbort = null;
826
1040
  sessionState.messages = messages;
827
1041
  sessionState.model = model;
@@ -888,12 +1102,16 @@ async function session({ flags }) {
888
1102
  const handleLineInner = async (rawLine) => {
889
1103
  const input = rawLine.trim();
890
1104
  if (!input) return;
1105
+ appendHistoryLine(tui.lastTyped?.() ?? input);
891
1106
  // First submit leaves home: the composer goes full session width.
892
1107
  tui.setHome?.(false);
893
1108
 
894
1109
  // Echo the submission as a first-class entry rather than two pre-formatted lines,
895
1110
  // so it wraps, re-wraps on resize, and carries its attachments.
896
- if (tui.user) tui.user(input, pendingImages.map((i) => i.name).filter(Boolean));
1111
+ if (tui.user) {
1112
+ const chips = [...input.matchAll(/\[Image #(\d+)\]/g)].map((m) => `image #${m[1]}`);
1113
+ tui.user(input, [...pendingImages.map((i) => i.name).filter(Boolean), ...chips]);
1114
+ }
897
1115
  else {
898
1116
  const bar = c.brand(glyph('│', '|'));
899
1117
  tui.print(`\n ${bar} ${c.bold(input.replace(/\n/g, ' '))}`, 'user');
@@ -908,6 +1126,10 @@ async function session({ flags }) {
908
1126
  if (input.startsWith('/')) {
909
1127
  const [verb, ...args] = input.split(/\s+/);
910
1128
  let result;
1129
+ // Busy while it runs, like any turn: /ask, /review, /research and the skills run
1130
+ // the agent, and without this Esc could not interrupt them and a message typed
1131
+ // meanwhile started a SECOND turn on the same conversation at the same time.
1132
+ tui.setBusy(true);
911
1133
  try {
912
1134
  result = await slashCommand(verb, args, {
913
1135
  client, messages, permissions, flags, spent, resetContext,
@@ -916,15 +1138,27 @@ async function session({ flags }) {
916
1138
  sessionState.model = m;
917
1139
  tui?.setMeta?.({ model: m });
918
1140
  },
919
- skills, sessionState, runManaged, tui, pendingImages, journal, setIntent,
1141
+ skills, sessionState, runManaged, runSide, abortable, tui, pendingImages, journal, setIntent,
920
1142
  });
921
1143
  } catch (error) {
922
1144
  // Slash commands run inside the fullscreen app. Letting this escape to the
923
1145
  // process-level handler prints to stderr outside the alternate screen and
924
1146
  // makes a real failure look like nothing happened.
925
1147
  reportAgentError(error);
1148
+ } finally {
1149
+ tui.setBusy(false);
1150
+ }
1151
+ if (result === 'exit' || result === 'logout') {
1152
+ // Saved first: /clear, /undo, /compact, /model then /exit used to lose the change.
1153
+ try { sessionState.messages = messages; sessionState.model = model; saveSession(sessionState); } catch { /* best effort */ }
1154
+ setTuiSink(null); tui.stop();
1155
+ if (result === 'logout') {
1156
+ const { default: logout } = await import('./commands/logout.js');
1157
+ await logout().catch((e) => fail(e.message));
1158
+ }
1159
+ process.exit(0);
926
1160
  }
927
- if (result === 'exit') { setTuiSink(null); tui.stop(); process.exit(0); }
1161
+ await drainQueued();
928
1162
  return;
929
1163
  }
930
1164
 
@@ -991,9 +1225,17 @@ async function session({ flags }) {
991
1225
  for (const note of mentioned.notes) tui.notice?.(note, 'dim');
992
1226
  }
993
1227
 
994
- // Image chips pasted with Ctrl+V ride first, then the text.
1228
+ // Images ride first, then the text: those whose [Image #N] chip is in the message,
1229
+ // plus any @image.png mentioned in it.
995
1230
  const pasted = tui.drainAttachments ? tui.drainAttachments(input) : [];
996
- const queuedImages = [...pendingImages.splice(0), ...pasted];
1231
+ const mentionedImages = [];
1232
+ for (const file of mentioned.images) {
1233
+ const ready = await shrinkImageFile(file).catch(() => null);
1234
+ const block = ready ? await imageBlock(ready).catch(() => null) : null;
1235
+ if (block) mentionedImages.push(block);
1236
+ else tui.notice?.(`@${path.basename(file)} could not be attached as an image (png, jpg, gif or webp, under 3.5 MB)`, 'warn');
1237
+ }
1238
+ const queuedImages = [...pendingImages.splice(0), ...pasted, ...mentionedImages];
997
1239
  if (queuedImages.length || mentioned.blocks.length) {
998
1240
  messages.push({ role: 'user', content: [...queuedImages, ...mentioned.blocks, { type: 'text', text: input }] });
999
1241
  } else {
@@ -1009,26 +1251,34 @@ async function session({ flags }) {
1009
1251
  reportAgentError(error, { title: 'That turn did not run' });
1010
1252
  } finally {
1011
1253
  tui.setBusy(false);
1012
- // While the agent was running, Enter queued whatever was typed — send those
1013
- // now, one at a time, each itself interruptible.
1014
- for (;;) {
1015
- const queued = tui.takeQueued()[0];
1016
- if (queued === undefined) break;
1017
- messages.push({ role: 'user', content: queued });
1018
- // Through the same entry type as a typed message. This was two pre-formatted
1019
- // lines each carrying a `│`, pushed in as raw text — which is where the doubled
1020
- // bar people saw after interrupting came from: the queue drains in the `finally`
1021
- // of an interrupted turn, so that is exactly when it showed up.
1022
- if (tui.user) tui.user(queued);
1023
- else tui.print(`\n ${c.brand(glyph('│', '|'))} ${c.bold(queued.replace(/\n/g, ' '))}`, 'user');
1024
- tui.setBusy(true);
1025
- await runManaged(messages);
1026
- tui.setBusy(false);
1027
- }
1028
1254
  await runHook(flags.cwd, 'afterTurn', { prompt: input, sessionId: sessionState.id });
1255
+ await drainQueued();
1029
1256
  }
1030
1257
  };
1031
1258
 
1259
+ /**
1260
+ * Whatever was typed while the agent worked. Each line goes through handleLineInner —
1261
+ * the same path as typing it now — so `/model`, `!ls`, `@file` and image chips in a
1262
+ * queued line work. They used to be pushed into the conversation as raw text: a queued
1263
+ * `/clear` became a message to the model.
1264
+ *
1265
+ * After Esc, the queue is handed back to the composer instead of sent: stopping a turn
1266
+ * and having the next queued message start immediately was not stopping.
1267
+ */
1268
+ async function drainQueued() {
1269
+ const queued = tui.takeQueued?.() || [];
1270
+ if (!queued.length) return;
1271
+ if (lastRunInterrupted) {
1272
+ lastRunInterrupted = false;
1273
+ tui.restoreInput?.(queued.join('\n'));
1274
+ tui.flash?.('Interrupted — your queued messages are back in the box', 'info', 2200);
1275
+ return;
1276
+ }
1277
+ const [next, ...rest] = queued;
1278
+ for (const line of rest) tui.queue?.(line);
1279
+ await handleLineInner(next);
1280
+ }
1281
+
1032
1282
  useHistoryFile(HISTORY);
1033
1283
  // ── Which renderer ──
1034
1284
  //
@@ -1057,11 +1307,58 @@ async function session({ flags }) {
1057
1307
  // is incapable.
1058
1308
  : !device.vt && !knownGoodTerminal && process.stdout.isTTY ? false
1059
1309
  : true;
1310
+ // Not going full-screen: the classic renderer, which prints straight to the terminal
1311
+ // while it is not running. The app view was used regardless — never started, so every
1312
+ // answer, /help and /status went into a transcript nobody could see, and pickers
1313
+ // (/model, /resume, /theme) waited forever on a dialog that was never drawn.
1314
+ if (!(process.stdout.isTTY && wantFullscreen) && createTui !== createClassicTui) {
1315
+ tui = await createClassicTui(tuiOptions);
1316
+ setTuiSink(tui);
1317
+ activeTui = tui;
1318
+ tui.onEscape?.(() => { if (activeAbort) activeAbort.abort(); });
1319
+ // Its dialog and panel draw only while it runs full-screen. Inline, a question is a
1320
+ // numbered list answered with one key, and a panel is printed.
1321
+ tui.askDialog = async (question, options = [], preview = []) => {
1322
+ out(` ${c.bold(String(question).replace(/\x1b\[[0-9;]*m/g, ''))}`);
1323
+ for (const line of preview.slice(0, 20)) out(` ${c.dim(String(line))}`);
1324
+ const list = options.slice(0, 9);
1325
+ list.forEach((o, i) => out(` ${c.brand(String(i + 1))}. ${String(o?.label ?? o?.value ?? o).replace(/\x1b\[[0-9;]*m/g, '')}`));
1326
+ if (!process.stdin.isTTY) return null;
1327
+ const keys = list.map((_, i) => String(i + 1));
1328
+ const key = await askKey(` ${c.dim(`1-${list.length}, or n to cancel ›`)} `, [...keys, 'n', '\x1b']);
1329
+ const picked = list[Number(key) - 1];
1330
+ return picked === undefined ? null : (picked && typeof picked === 'object' ? picked : { value: picked });
1331
+ };
1332
+ tui.showPanel = async (title, lines) => {
1333
+ out(` ${c.bold(String(title))}`);
1334
+ for (const line of (Array.isArray(lines) ? lines : String(lines).split('\n'))) out(` ${line}`);
1335
+ return null;
1336
+ };
1337
+ }
1060
1338
  if (process.stdout.isTTY && wantFullscreen) {
1061
1339
  tui.start({ onLine: handleLine, history: sessionHistory() });
1062
1340
  // A global install never updates itself; say so when there is something newer.
1063
1341
  // Background and cached for a day — the app never waits on the network for this.
1064
1342
  void updateNotice().then((line) => { if (line) tui.notice(line, 'info'); }).catch(() => {});
1343
+ // A project's .mcp.json runs programs on this machine. Nothing in it starts until the
1344
+ // exact command is approved here — asked once, remembered per project and command.
1345
+ void (async () => {
1346
+ for (const server of untrustedServers(flags.cwd)) {
1347
+ const line = [server.config.command, ...(server.config.args || [])].join(' ');
1348
+ const answer = dialogValue(await tui.askDialog(
1349
+ `This project's .mcp.json wants to start an MCP server "${server.name}". It runs on your computer, with your files.`,
1350
+ [{ label: 'Allow and remember for this project', value: 'trust' }, { label: 'Not now', value: 'no' }],
1351
+ [`command: ${line.slice(0, 300)}`, ...(server.config.env ? [`env: ${Object.keys(server.config.env).join(', ')}`] : [])],
1352
+ { title: 'MCP SERVER', hideComment: true },
1353
+ ));
1354
+ if (answer === 'trust') { trustServer(flags.cwd, server); tui.notice(`MCP server ${server.name} approved for this project.`, 'info'); }
1355
+ else tui.notice(`MCP server ${server.name} not started. \`jaitus mcp trust ${server.name}\` approves it later.`, 'dim');
1356
+ }
1357
+ // Problems starting an approved server are said once, not swallowed.
1358
+ const tools = await discoverMcpTools(flags.cwd).catch(() => []);
1359
+ for (const [name, why] of mcpProblems) tui.notice(`MCP server ${name} could not start: ${String(why).slice(0, 160)}`, 'warn');
1360
+ void tools;
1361
+ })().catch(() => {});
1065
1362
  // Fresh sessions open on the home moment, opencode-style: the centered
1066
1363
  // two-tone mark, then a tip — the composer below is the whole welcome.
1067
1364
  // The mark is vertically centered in the free space above the pinned
@@ -1249,17 +1546,42 @@ function completeFiles(frag) {
1249
1546
  let currentCwd = process.cwd();
1250
1547
 
1251
1548
  /** Prior prompts for Up-arrow recall: this session first, then earlier ones. */
1549
+ /**
1550
+ * Prompt history across sessions, oldest first (what the app's Up/Down walks).
1551
+ *
1552
+ * The file is one entry per line, oldest first — the format the classic prompt writes.
1553
+ * This used to JSON.parse it, which always failed, so history never carried over; and
1554
+ * the app never wrote to it at all. A newline inside an entry is stored as U+2028 so
1555
+ * each entry stays one line.
1556
+ */
1557
+ // U+2028: a newline inside a history entry, so each entry stays one line of the file.
1558
+ const LINE_SEP = String.fromCharCode(0x2028);
1559
+ const NL = String.fromCharCode(10);
1560
+ const NEWLINE_RE = new RegExp('\\r?\\n');
1561
+
1252
1562
  function sessionHistory() {
1253
1563
  try {
1254
1564
  const file = path.join(HOME, 'history');
1255
1565
  if (!fs.existsSync(file)) return [];
1256
- const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
1257
- return Array.isArray(parsed) ? parsed.slice(-50).reverse() : [];
1566
+ return fs.readFileSync(file, 'utf8').split(NEWLINE_RE).filter(Boolean).slice(-200).map((l) => l.split(LINE_SEP).join(NL));
1258
1567
  } catch {
1259
1568
  return [];
1260
1569
  }
1261
1570
  }
1262
1571
 
1572
+ function appendHistoryLine(line) {
1573
+ const entry = String(line || '').trim().split(NEWLINE_RE).join(LINE_SEP);
1574
+ if (!entry) return;
1575
+ try {
1576
+ const file = path.join(HOME, 'history');
1577
+ const lines = fs.existsSync(file) ? fs.readFileSync(file, 'utf8').split(NEWLINE_RE).filter(Boolean) : [];
1578
+ if (lines.at(-1) === entry) return;
1579
+ lines.push(entry);
1580
+ fs.mkdirSync(path.dirname(file), { recursive: true });
1581
+ fs.writeFileSync(file, lines.slice(-500).join(NL) + NL, { mode: 0o600 });
1582
+ } catch { /* history is a convenience, never a reason to fail */ }
1583
+ }
1584
+
1263
1585
  /** Append one line to the project memory file, creating it with a header if needed. */
1264
1586
  function appendMemory(cwd, note) {
1265
1587
  const file = path.join(cwd, CONTEXT_FILE);
@@ -1315,11 +1637,14 @@ async function relaunchRenderer(ctx, want) {
1315
1637
  setTuiSink(null);
1316
1638
  ctx.tui.stop();
1317
1639
  const bin = fileURLToPath(new URL('../bin/jaitus.js', import.meta.url));
1318
- const argv = process.argv.slice(2).filter((a) => a !== '--resume' && a !== '-r' && a !== '--continue' && a !== '-c');
1319
- // Drop the value that followed --resume/-r, if any.
1640
+ // Drop --resume/-r WITH its value, and --continue/-c. The flags used to be filtered
1641
+ // out first, so the loop below never saw them and the old id stayed behind — the new
1642
+ // process read it as a task prompt and exited.
1643
+ const argv = process.argv.slice(2);
1320
1644
  const cleaned = [];
1321
1645
  for (let i = 0; i < argv.length; i++) {
1322
- if ((argv[i] === '--resume' || argv[i] === '-r')) { i++; continue; }
1646
+ if (argv[i] === '--resume' || argv[i] === '-r') { i++; continue; }
1647
+ if (argv[i] === '--continue' || argv[i] === '-c') continue;
1323
1648
  cleaned.push(argv[i]);
1324
1649
  }
1325
1650
  out(c.dim(` Relaunching in ${want} mode — conversation continues where it left off…`));
@@ -1331,9 +1656,18 @@ async function relaunchRenderer(ctx, want) {
1331
1656
  * Single-select picker for anything listable (sessions, models, modes, jobs):
1332
1657
  * arrows + Enter, 1-9 jump, Esc cancels. Returns the value or undefined.
1333
1658
  */
1659
+ /** Put the (changed) conversation back on screen: after /undo and /rewind the dropped turns stayed visible. */
1660
+ function redrawConversation(ctx) {
1661
+ try {
1662
+ if (!ctx.tui?.clearTranscript) return;
1663
+ ctx.tui.clearTranscript();
1664
+ if (ctx.messages.length) replaySession(ctx.tui, ctx.messages);
1665
+ } catch { /* the conversation itself is already right; only the view is stale */ }
1666
+ }
1667
+
1334
1668
  async function pickOption(tui, title, question, options, preview = []) {
1335
1669
  if (!options.length) return undefined;
1336
- const r = await tui.askDialog(question, options.slice(0, 9), preview, { title, hideComment: true });
1670
+ const r = await tui.askDialog(question, options.slice(0, 20), preview, { title, hideComment: true });
1337
1671
  const value = r && typeof r === 'object' ? r.value : r;
1338
1672
  return value == null ? undefined : value;
1339
1673
  }
@@ -1406,20 +1740,39 @@ async function slashCommand(verb, args, ctx) {
1406
1740
  }
1407
1741
 
1408
1742
  case '/export': {
1409
- const target = args[0] || `jaitus-session-${new Date().toISOString().slice(0, 16).replace(/[:T]/g, '-')}.md`;
1743
+ const target = args.join(' ').replace(/^["']|["']$/g, '') || `jaitus-session-${new Date().toISOString().slice(0, 16).replace(/[:T]/g, '-')}.md`;
1410
1744
  try {
1411
- const lines = [`# Jaitus session — ${new Date().toISOString()}`, ''];
1412
- for (const m of ctx.messages) {
1413
- if (m.role === 'user' && typeof m.content === 'string') lines.push(`## You\n\n${m.content}\n`);
1414
- else if (m.role === 'assistant') {
1415
- const text = (Array.isArray(m.content) ? m.content : [])
1416
- .filter((b) => b.type === 'text').map((b) => b.text).join('\n');
1417
- const tools = (Array.isArray(m.content) ? m.content : []).filter((b) => b.type === 'tool_use');
1418
- if (text) lines.push(`## Jaitus\n\n${text}\n`);
1419
- for (const t of tools) lines.push(`> ${c.grey('')}\`${t.name} ${JSON.stringify(t.input).slice(1, 120)}\``);
1745
+ const ext = path.extname(target).toLowerCase();
1746
+ const blocks = (m) => (Array.isArray(m.content) ? m.content : [{ type: 'text', text: String(m.content ?? '') }]);
1747
+ let body;
1748
+ if (ext === '.json') {
1749
+ // The conversation as the model saw it, images included.
1750
+ body = JSON.stringify({ session: ctx.sessionState.id, model: ctx.model, exportedAt: new Date().toISOString(), messages: ctx.messages }, null, 2);
1751
+ } else {
1752
+ // Markdown or plain text. User turns with an image or an @mention are content
1753
+ // arrays and used to be left out; ANSI colour codes used to land in the file.
1754
+ const md = ext !== '.txt';
1755
+ const lines = [md ? `# Jaitus session — ${new Date().toISOString()}` : `Jaitus session — ${new Date().toISOString()}`, ''];
1756
+ for (const m of ctx.messages) {
1757
+ const parts = blocks(m);
1758
+ const text = parts.filter((b) => b.type === 'text' && !/^<mentioned /.test(b.text || '')).map((b) => b.text).join('\n').trim();
1759
+ const extras = [
1760
+ ...parts.filter((b) => b.type === 'image').map(() => '[image]'),
1761
+ ...parts.filter((b) => b.type === 'text' && /^<mentioned path="([^"]+)"/.test(b.text || '')).map((b) => `[@${/path="([^"]+)"/.exec(b.text)[1]}]`),
1762
+ ];
1763
+ if (m.role === 'user' && (text || extras.length) && !parts.every((b) => b.type === 'tool_result')) {
1764
+ lines.push(md ? `## You\n\n${[text, extras.join(' ')].filter(Boolean).join('\n\n')}\n` : `YOU: ${[text, extras.join(' ')].filter(Boolean).join(' ')}\n`);
1765
+ } else if (m.role === 'assistant') {
1766
+ if (text) lines.push(md ? `## Jaitus\n\n${text}\n` : `JAITUS: ${text}\n`);
1767
+ for (const t of parts.filter((b) => b.type === 'tool_use')) {
1768
+ const call = `${t.name} ${JSON.stringify(t.input).slice(0, 160)}`;
1769
+ lines.push(md ? `> \`${call}\`` : ` (ran ${call})`);
1770
+ }
1771
+ }
1420
1772
  }
1773
+ body = lines.join('\n');
1421
1774
  }
1422
- fs.writeFileSync(path.resolve(ctx.flags.cwd, target), lines.join('\n'), 'utf8');
1775
+ fs.writeFileSync(path.resolve(ctx.flags.cwd, target), body, 'utf8');
1423
1776
  ok(`Exported to ${c.bold(target)}`);
1424
1777
  } catch (e) {
1425
1778
  fail(`Export failed: ${e.message}`);
@@ -1441,22 +1794,32 @@ async function slashCommand(verb, args, ctx) {
1441
1794
  case '/image': {
1442
1795
  const [p] = args;
1443
1796
  if (!p) { warn('Give an image path: /image screenshot.png (png, jpg, gif, webp — max ~3 MB).'); return null; }
1444
- const abs = path.isAbsolute(p) ? p : path.join(ctx.flags.cwd, p);
1445
- const block = await imageBlock(abs);
1446
- if (!block) { warn(`Cannot attach ${p} — not a supported image (png/jpg/jpeg/gif/webp), too big, or unreadable.`); return null; }
1797
+ // A path with spaces arrives split into args; quotes around it are the user's.
1798
+ const joined = args.join(' ').replace(/^["']|["']$/g, '');
1799
+ const abs = path.isAbsolute(joined) ? joined : path.join(ctx.flags.cwd, joined);
1800
+ // The app view: a chip in the composer, removable with Backspace, shrunk if large.
1801
+ if (ctx.tui?.addImageFiles) { await ctx.tui.addImageFiles([abs]); return null; }
1802
+ const ready = await shrinkImageFile(abs).catch(() => null);
1803
+ const block = ready ? await imageBlock(ready) : null;
1804
+ if (!block) { warn(`Cannot attach ${joined} — not a supported image (png/jpg/jpeg/gif/webp), unreadable, or could not be made smaller than 3.5 MB.`); return null; }
1447
1805
  ctx.pendingImages.push(block);
1448
1806
  ok(`Image attached (${Math.round((block.source.data.length * 3) / 4 / 1024)} KB). It is sent with your next message.`);
1449
1807
  return null;
1450
1808
  }
1451
1809
 
1452
1810
  case '/diff': {
1453
- const [stat, diff] = await Promise.all([
1454
- captureProcess('git', ['diff', '--stat'], ctx.flags.cwd),
1455
- captureProcess('git', ['diff', '--no-ext-diff', '--unified=3'], ctx.flags.cwd),
1811
+ // Everything not yet committed: staged and unstaged (git diff HEAD), plus new files
1812
+ // git does not track yet. Plain `git diff` showed only unstaged edits and missed
1813
+ // every file the agent had created.
1814
+ const [stat, diff, untracked] = await Promise.all([
1815
+ captureProcess('git', ['diff', 'HEAD', '--stat'], ctx.flags.cwd),
1816
+ captureProcess('git', ['diff', 'HEAD', '--no-ext-diff', '--unified=3'], ctx.flags.cwd),
1817
+ captureProcess('git', ['ls-files', '--others', '--exclude-standard'], ctx.flags.cwd),
1456
1818
  ]);
1457
1819
  if (stat.error || diff.error) { warn('Not a git repository, or git is not installed.'); return null; }
1458
- const body = `${stat.stdout}${stat.stdout && diff.stdout ? '\n\n' : ''}${diff.stdout}`.trim()
1459
- || 'No uncommitted changes.';
1820
+ const fresh = String(untracked.stdout || '').trim();
1821
+ const body = [stat.stdout.trim(), fresh ? `New files, not yet tracked:\n${fresh.split('\n').map((f) => ` + ${f}`).join('\n')}` : '', diff.stdout.trim()]
1822
+ .filter(Boolean).join('\n\n') || 'No uncommitted changes.';
1460
1823
  if (ctx.tui) await ctx.tui.showPanel('Changes', body.split('\n'));
1461
1824
  else out(body);
1462
1825
  return null;
@@ -1536,7 +1899,12 @@ async function slashCommand(verb, args, ctx) {
1536
1899
  }
1537
1900
  const rule = args.slice(1).join(' ').trim();
1538
1901
  if (!rule) { warn(`Give a rule: /permissions ${next} run_command(npm test:*)`); return null; }
1539
- if (next === 'forget') { ctx.permissions.removeRule(rule); ok(`Forgot ${c.bold(rule)}.`); return null; }
1902
+ if (next === 'forget') {
1903
+ const { allow, deny, session } = ctx.permissions.rules;
1904
+ if (![...allow, ...deny, ...session].includes(rule)) { warn(`No rule ${c.bold(rule)} — /permissions rules lists them.`); return null; }
1905
+ if (DEFAULT_DENY.includes(rule)) { warn(`${c.bold(rule)} is a built-in safety rule; it can only be lifted for this session.`); }
1906
+ ctx.permissions.removeRule(rule); ok(`Forgot ${c.bold(rule)}.`); return null;
1907
+ }
1540
1908
  if (!parseRule(rule)) { warn(`That is not a rule. Try ${c.bold('run_command(npm test:*)')} or ${c.bold('edit_file(src/**)')}.`); return null; }
1541
1909
  ctx.permissions.addRule(rule, { persist: true, kind: next });
1542
1910
  ok(`${next === 'deny' ? 'Denied' : 'Allowed'}: ${c.bold(rule)} — saved for this project.`);
@@ -1633,7 +2001,7 @@ async function slashCommand(verb, args, ctx) {
1633
2001
  info(`Pick one: ${c.bold('/resume <id>')} — ${all.slice(0, 9).map((s) => s.id.slice(0, 8)).join(' ')}`);
1634
2002
  return null;
1635
2003
  }
1636
- const all = listSessions({ cwd: ctx.flags.cwd }).slice(0, 9);
2004
+ const all = listSessions({ cwd: ctx.flags.cwd }).slice(0, 20);
1637
2005
  if (!all.length) { info('No saved sessions in this directory yet.'); return null; }
1638
2006
  const chosen = await pickOption(ctx.tui, 'Resume session',
1639
2007
  'Load a saved session from this directory?',
@@ -1710,6 +2078,12 @@ async function slashCommand(verb, args, ctx) {
1710
2078
  }
1711
2079
 
1712
2080
  case '/vim': {
2081
+ // The full-screen view has no vim mode. It used to report "Vim editing on — Esc for
2082
+ // normal mode", and Esc then started the rewind countdown.
2083
+ if (ctx.tui?.askDialog) {
2084
+ warn('Vim editing is only in the classic prompt (/tui default). The full-screen view uses standard keys: arrows, Home/End, Ctrl+A/E, Ctrl+W, Ctrl+U/K.');
2085
+ return null;
2086
+ }
1713
2087
  const { loadSettings: loadSv, saveSettings: saveSv } = await import('./config.js');
1714
2088
  const on = loadSv().editorMode !== 'vim';
1715
2089
  saveSv({ ...loadSv(), editorMode: on ? 'vim' : 'emacs' });
@@ -1819,10 +2193,9 @@ async function slashCommand(verb, args, ctx) {
1819
2193
  const prompt = args.join(' ').trim();
1820
2194
  if (!prompt) { info('Usage: /agent inspect the authentication flow'); return null; }
1821
2195
  const sideMessages = [{ role: 'user', content: prompt }];
1822
- const sidePermissions = createPermissions({ mode: 'plan' });
1823
- const side = await runAgent({
1824
- client: ctx.client, messages: sideMessages, model: ctx.model, cwd: ctx.flags.cwd,
1825
- permissions: sidePermissions, extraSystem: `${skillCatalog(ctx.skills)}\n\nYou are a focused Jaitus side-agent. Return a concise finding; never edit files or run commands.`,
2196
+ const side = await ctx.runSide({
2197
+ messages: sideMessages, mode: 'plan',
2198
+ extraSystem: `${skillCatalog(ctx.skills)}\n\nYou are a focused Jaitus side-agent. Return a concise finding; never edit files or run commands.`,
1826
2199
  });
1827
2200
  info(`Side-agent finished (${side.tools || 0} read/tool calls).`);
1828
2201
  return null;
@@ -1854,7 +2227,14 @@ async function slashCommand(verb, args, ctx) {
1854
2227
 
1855
2228
  case '/code':
1856
2229
  ctx.setIntent('build');
1857
- ok('Implementation intent — approval policy remains unchanged.');
2230
+ // /ask, /plan and /architect switch to plan mode; /code has to switch back, or the
2231
+ // agent still cannot write anything after being told to build.
2232
+ if (ctx.permissions.mode === 'plan') {
2233
+ ctx.permissions.setMode('default');
2234
+ ok('Building — plan mode is off; it asks before each change (Shift+Tab to change that).');
2235
+ } else {
2236
+ ok('Building — the approval mode is unchanged.');
2237
+ }
1858
2238
  return null;
1859
2239
 
1860
2240
  case '/explain':
@@ -1895,9 +2275,8 @@ async function slashCommand(verb, args, ctx) {
1895
2275
  const question = args.join(' ').trim();
1896
2276
  if (!question) { info('Usage: /btw <quick question> — answered off the record.'); return null; }
1897
2277
  const sideMessages = [{ role: 'user', content: question }];
1898
- await runAgent({
1899
- client: ctx.client, messages: sideMessages, model: ctx.model, cwd: ctx.flags.cwd,
1900
- permissions: createPermissions({ mode: ctx.permissions?.mode || 'default' }),
2278
+ await ctx.runSide({
2279
+ messages: sideMessages, mode: ctx.permissions?.mode || 'default',
1901
2280
  extraSystem: `${skillCatalog(ctx.skills)}\n\nYou are answering a quick side question. Be brief; this answer is shown once and never stored.`,
1902
2281
  });
1903
2282
  info(c.dim('Off the record — the conversation above is untouched.'));
@@ -1946,6 +2325,10 @@ async function slashCommand(verb, args, ctx) {
1946
2325
  if (!outcome.ok) { fail(`Rewind failed: ${outcome.error}`); return null; }
1947
2326
  ctx.sessionState.messages = ctx.messages;
1948
2327
  saveSession(ctx.sessionState);
2328
+ // The context meter and the screen follow the rewound conversation (both stayed
2329
+ // on the old one: the undone turns were still visible).
2330
+ ctx.resetContext?.();
2331
+ redrawConversation(ctx);
1949
2332
  ok(`Rewound to turn ${index + 1} — ${outcome.messageCount} message${outcome.messageCount === 1 ? '' : 's'} restored`
1950
2333
  + (outcome.restored.length || outcome.deleted.length
1951
2334
  ? c.dim(` · ${outcome.restored.length} file${outcome.restored.length === 1 ? '' : 's'} reverted${outcome.deleted.length ? `, ${outcome.deleted.length} removed` : ''}`)
@@ -1994,7 +2377,8 @@ async function slashCommand(verb, args, ctx) {
1994
2377
  try {
1995
2378
  const line = `[${new Date().toISOString()}] session=${ctx.sessionState.id} model=${ctx.model} cwd=${ctx.flags.cwd} turns=${spent.turns}\n ${text.replace(/\n/g, '\n ')}\n`;
1996
2379
  fs.appendFileSync(path.join(HOME, 'feedback.log'), line, 'utf8');
1997
- ok(`Noted in ${c.bold('~/.jaitus/feedback.log')} — thank you. ${c.dim('/export sends the full conversation with it.')}`);
2380
+ // Saved here only — it is not sent anywhere, and saying otherwise was untrue.
2381
+ ok(`Saved to ${c.bold('~/.jaitus/feedback.log')} on this computer (it is not sent anywhere). ${c.dim('/export writes the conversation to a file if you want to share it.')}`);
1998
2382
  } catch (e) {
1999
2383
  fail(`Could not save feedback: ${e.message}`);
2000
2384
  }
@@ -2034,11 +2418,11 @@ async function slashCommand(verb, args, ctx) {
2034
2418
  return null;
2035
2419
  }
2036
2420
 
2037
- case '/logout': {
2038
- const { default: logout } = await import('./commands/logout.js');
2039
- await logout();
2040
- return 'exit';
2041
- }
2421
+ case '/logout':
2422
+ // Run after the app closes (see 'logout' in handleLineInner): its result — and a
2423
+ // warning if the server could not be told — went into a transcript that closed at
2424
+ // once, so nobody saw it.
2425
+ return 'logout';
2042
2426
 
2043
2427
  case '/doctor': {
2044
2428
  const { default: doctor } = await import('./commands/doctor.js');
@@ -2094,10 +2478,18 @@ async function slashCommand(verb, args, ctx) {
2094
2478
 
2095
2479
  case '/undo': {
2096
2480
  // A turn is the user message plus everything the assistant added after it.
2481
+ // A prompt is a user message that is not just tool results — one with an image or
2482
+ // an @mention is an array, and was skipped, so /undo dropped TWO exchanges.
2483
+ const isPrompt = (m) => m.role === 'user' && (typeof m.content === 'string'
2484
+ || (Array.isArray(m.content) && m.content.some((b) => b.type !== 'tool_result')));
2097
2485
  let i = messages.length - 1;
2098
- while (i >= 0 && !(messages[i].role === 'user' && typeof messages[i].content === 'string')) i--;
2099
- if (i >= 0) { messages.length = i; ctx.resetContext?.(); ok('Dropped the last exchange.'); }
2100
- else warn('Nothing to undo.');
2486
+ while (i >= 0 && !isPrompt(messages[i])) i--;
2487
+ if (i >= 0) {
2488
+ messages.length = i;
2489
+ ctx.resetContext?.();
2490
+ redrawConversation(ctx);
2491
+ ok('Dropped the last exchange.');
2492
+ } else warn('Nothing to undo.');
2101
2493
  return null;
2102
2494
  }
2103
2495
 
@@ -2119,7 +2511,7 @@ async function slashCommand(verb, args, ctx) {
2119
2511
  if (!available.length) { warn('The gateway returned no models.'); return null; }
2120
2512
  if (!ctx.tui) { info(`Model: ${c.bold(ctx.model)}`); return null; }
2121
2513
  const picked = await pickOption(ctx.tui, 'Model', 'Which model serves this session?',
2122
- available.slice(0, 9).map((m) => ({ value: m, label: `${m === ctx.model ? '●' : '○'} ${m}` })));
2514
+ available.slice(0, 20).map((m) => ({ value: m, label: `${m === ctx.model ? '●' : '○'} ${m}` })));
2123
2515
  if (!picked) return null;
2124
2516
  applyModel(picked);
2125
2517
  return null;
@@ -2276,23 +2668,37 @@ const commands = {
2276
2668
  summary: 'list or configure MCP tool servers',
2277
2669
  usage: [
2278
2670
  'jaitus mcp list',
2279
- 'jaitus mcp add <name> <command> [args...]',
2671
+ 'jaitus mcp add [--global] <name> -- <command> [args...]',
2672
+ 'jaitus mcp trust <name>',
2280
2673
  'jaitus mcp remove <name>',
2281
2674
  ],
2282
- detail: ['Local stdio servers are read from .mcp.json and exposed to the agent as permission-gated tools.'],
2675
+ detail: [
2676
+ 'Local stdio servers from your own ~/.jaitus/mcp.json (--global) and the project\'s .mcp.json, merged,',
2677
+ 'exposed to the agent as permission-gated tools. A project\'s servers start only once you approve them.',
2678
+ 'Put the command after -- so its own flags (-y, --port) are not read as Jaitus flags.',
2679
+ ],
2283
2680
  run: (argv, { json, flags }) => {
2284
- const [action = 'list', name, command, ...args] = argv;
2681
+ const global = argv.includes('--global');
2682
+ const rest = argv.filter((a) => a !== '--global');
2683
+ const [action = 'list', name, command, ...args] = rest;
2285
2684
  if (action === 'list') {
2286
2685
  const servers = listMcpServers(flags.cwd);
2287
2686
  present(json, { servers }, () => {
2288
2687
  if (!servers.length) { info('No MCP servers configured.'); return; }
2289
- servers.forEach((server) => out(` ${c.brand(server.name)} ${server.transport} ${server.command}`));
2688
+ servers.forEach((server) => out(` ${c.brand(server.name)} ${c.dim(server.scope)} ${server.trusted ? c.accent('approved') : c.yellow('not approved')} ${server.command}`));
2290
2689
  });
2291
2690
  return 0;
2292
2691
  }
2293
2692
  if (action === 'add' && name && command) {
2294
- addMcpServer(flags.cwd, name, { type: 'stdio', command, args });
2295
- ok(`Added MCP server ${c.bold(name)}.`);
2693
+ addMcpServer(flags.cwd, name, { type: 'stdio', command, args }, { global });
2694
+ ok(`Added MCP server ${c.bold(name)}${global ? ' for every project' : ''}.`);
2695
+ return 0;
2696
+ }
2697
+ if (action === 'trust' && name) {
2698
+ const server = allServers(flags.cwd).find((s) => s.name === name);
2699
+ if (!server) { warn(`No MCP server named ${name}.`); return 1; }
2700
+ trustServer(flags.cwd, server);
2701
+ ok(`Approved ${c.bold(name)} for this project: ${[server.config.command, ...(server.config.args || [])].join(' ')}`);
2296
2702
  return 0;
2297
2703
  }
2298
2704
  if (action === 'remove' && name) {
@@ -2300,7 +2706,7 @@ const commands = {
2300
2706
  ok(`Removed MCP server ${c.bold(name)}.`);
2301
2707
  return 0;
2302
2708
  }
2303
- fail('Usage: jaitus mcp list | add <name> <command> [args...] | remove <name>');
2709
+ fail('Usage: jaitus mcp list | add [--global] <name> -- <command> [args...] | trust <name> | remove <name>');
2304
2710
  return 1;
2305
2711
  },
2306
2712
  },
@@ -2311,7 +2717,8 @@ const commands = {
2311
2717
  'jaitus config show everything',
2312
2718
  'jaitus config model show one value',
2313
2719
  'jaitus config model <id> set it',
2314
- 'jaitus config theme <name> dark, light, daltonized, plain, auto',
2720
+ 'jaitus config theme <name> dark, light, ocean, daltonized, auto',
2721
+ 'jaitus config <key> --unset back to the default',
2315
2722
  'jaitus config copyOnSelect <on|off> auto-copy mouse selection',
2316
2723
  ],
2317
2724
  detail: [
@@ -2324,7 +2731,11 @@ const commands = {
2324
2731
  'Stack Overflow directly, which covers most of what a coding agent asks.',
2325
2732
  ],
2326
2733
  run: (argv, { json }) => {
2327
- const [key, value] = argv;
2734
+ // The value is everything after the key: `config statusLine node status.js` used to
2735
+ // save just "node".
2736
+ const [key, ...rest] = argv;
2737
+ const unset = rest.length === 1 && /^(--unset|none|unset)$/i.test(rest[0]);
2738
+ const value = unset ? '' : rest.join(' ').trim();
2328
2739
  const settings = loadSettings();
2329
2740
  const ONOFF = ['copyOnSelect', 'wheelAccel', 'autoScroll', 'animations', 'autoCompact', 'mouse', 'autoUpdate'];
2330
2741
  const SECRET = ['braveSearchKey', 'tavilyKey', 'serperKey'];
@@ -2352,7 +2763,7 @@ const commands = {
2352
2763
  ['Gateway', data.gateway],
2353
2764
  ['Website', data.website],
2354
2765
  ['Model', data.model || 'whatever the gateway lists first'],
2355
- ['Theme', `${data.theme} (dark, light, daltonized, plain, auto)`],
2766
+ ['Theme', `${data.theme} (${[...THEME_NAMES, 'auto'].join(', ')})`],
2356
2767
  ['Copy on select', data.copyOnSelect ? 'on (drag selects, auto-copies)' : 'off (Ctrl+Shift+C copies)'],
2357
2768
  ['Scroll speed', String(data.scrollSpeed)],
2358
2769
  ['Wheel accel', data.wheelAccel ? 'on' : 'off'],
@@ -2370,6 +2781,14 @@ const commands = {
2370
2781
  fail(`Unknown setting: ${key}. Try model, baseUrl, siteUrl, theme, statusLine, ${ONOFF.join(', ')}, ${SECRET.join(', ')} or scrollSpeed.`);
2371
2782
  return 1;
2372
2783
  }
2784
+ // Clearing a value (there was no way: an empty value just printed the current one).
2785
+ if (unset) {
2786
+ if (!['model', 'baseUrl', 'siteUrl', 'statusLine', 'theme', 'scrollSpeed', ...SECRET].includes(key)) { fail(`${key} is on/off — set it with on or off.`); return 1; }
2787
+ delete settings[key];
2788
+ saveSettings(settings);
2789
+ ok(`${key} cleared — back to the default.`);
2790
+ return 0;
2791
+ }
2373
2792
  if (!value) {
2374
2793
  // Never print a key back. Whether it is set, and how long it is, answers every
2375
2794
  // question anyone actually has about it.
@@ -2381,7 +2800,9 @@ const commands = {
2381
2800
  out(String(settings[key] ?? '')); return 0;
2382
2801
  }
2383
2802
  if (key === 'theme') {
2384
- if (!['dark', 'light', 'daltonized', 'plain', 'auto'].includes(value)) { fail('Use dark, light, daltonized, plain or auto.'); return 1; }
2803
+ // The themes that exist (ocean was refused; plain was accepted and did nothing).
2804
+ const names = [...THEME_NAMES, 'auto'];
2805
+ if (!names.includes(value)) { fail(`Use ${names.join(', ')}.`); return 1; }
2385
2806
  settings.theme = value;
2386
2807
  saveSettings(settings);
2387
2808
  ok(`theme = ${value}`);
@@ -2435,7 +2856,7 @@ const commands = {
2435
2856
  update: {
2436
2857
  summary: 'check whether a newer version is published',
2437
2858
  usage: ['jaitus update [--json]'],
2438
- detail: ['Reports; does not install. How this CLI was installed is yours to decide.'],
2859
+ detail: ['Checks npm for a newer version and, for a global npm install, offers to install it (--yes installs without asking). Launching jaitus also updates itself; turn that off with `jaitus config autoUpdate off`.'],
2439
2860
  run: (argv, { json }) => update(argv, { json }),
2440
2861
  },
2441
2862
 
@@ -2493,13 +2914,41 @@ export default async function main(argv) {
2493
2914
  const { flags, rest, passthrough } = parseArgs(argv);
2494
2915
 
2495
2916
  if (flags.version) { out(VERSION); return 0; }
2917
+ // A mistyped mode used to become `default` silently — the one flag whose silent
2918
+ // fallback changes what the agent is allowed to do.
2919
+ if (flags.permissionModeExplicit && !flags.permissionMode) {
2920
+ fail('Unknown --permission-mode. Use one of: default, acceptEdits, plan, bypassPermissions.');
2921
+ return 1;
2922
+ }
2923
+ if (flags.maxTurnsRaw !== undefined && !(Number.isInteger(flags.maxTurns) && flags.maxTurns >= 1)) {
2924
+ fail(`--max-turns needs a whole number of steps, 1 or more (got ${flags.maxTurnsRaw === undefined ? 'nothing' : JSON.stringify(flags.maxTurnsRaw)}).`);
2925
+ return 1;
2926
+ }
2927
+ if (!['text', 'json', 'stream-json'].includes(flags.outputFormat)) {
2928
+ fail(`Unknown --output-format ${flags.outputFormat}. Use text, json or stream-json.`);
2929
+ return 1;
2930
+ }
2931
+ if (flags.cwd && !fs.existsSync(flags.cwd)) {
2932
+ fail(`--cwd ${flags.cwd} does not exist.`);
2933
+ return 1;
2934
+ }
2496
2935
 
2497
2936
  // Always on the newest version: install it and reopen before doing anything else.
2498
2937
  const relaunched = await autoUpdate({ argv, settings: loadSettings() }).catch(() => null);
2499
2938
  if (relaunched !== null) return relaunched;
2500
2939
 
2501
2940
  const [first, ...others] = rest;
2502
- const cmd = commands[first];
2941
+ // Own properties only: `jaitus constructor` used to find Object.prototype.constructor.
2942
+ let cmd = Object.prototype.hasOwnProperty.call(commands, first) ? commands[first] : null;
2943
+ // A command word followed by more words than that command takes is a PROMPT that
2944
+ // happens to start with it: `jaitus logout button is broken, fix it` signed this
2945
+ // computer out, and `jaitus update the changelog` ran the updater.
2946
+ const MAX_POSITIONAL = { login: 0, logout: 0, whoami: 0, usage: 0, models: 0, sessions: 0, doctor: 0, update: 0, banner: 0, version: 0, init: 0, completion: 1, help: 1 };
2947
+ if (cmd && Object.prototype.hasOwnProperty.call(MAX_POSITIONAL, first)) {
2948
+ const words = others.filter((a) => !a.startsWith('-'));
2949
+ // `help <one word>` stays help (a typo gets "No such command"); `help me fix …` is a task.
2950
+ if (words.length > MAX_POSITIONAL[first]) cmd = null;
2951
+ }
2503
2952
 
2504
2953
  if (flags.help) {
2505
2954
  if (cmd) return commandHelp(first);
@@ -2523,8 +2972,8 @@ export default async function main(argv) {
2523
2972
  return 1;
2524
2973
  }
2525
2974
 
2526
- const piped = await readStdin();
2527
2975
  const prompt = rest.join(' ').trim();
2976
+ const piped = await readStdin({ patient: Boolean(prompt) || flags.print });
2528
2977
 
2529
2978
  if (prompt) {
2530
2979
  const task = piped ? `${prompt}\n\n--- piped input ---\n${piped}` : prompt;