jaitus 0.3.3 → 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,28 +81,48 @@ 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,
84
- print: false, continue: false, resume: null, outputFormat: 'text', maxTurns: 40,
93
+ print: false, continue: false, resume: null, outputFormat: 'text', maxTurns: undefined,
85
94
  permissionMode: 'default', systemPrompt: '', appendSystemPrompt: '',
86
95
  };
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]) || 40);
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,16 +930,65 @@ 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;
986
+ // The new message counts toward the context from the moment it is sent.
987
+ try { showContext(); } catch { /* header keeps last value */ }
781
988
  try {
782
989
  const result = await runAgent({
783
990
  client, messages, model, cwd: flags.cwd, permissions, signal: ctrl.signal, tui, journal,
991
+ onContext: (n) => { lastContext = n; sessionState.lastContext = n; showContext(); },
784
992
  extraSystem: `${skillCatalog(skills)}\n\nCurrent intent: ${intentMode}. ${intentGuidance[intentMode]}${flags.appendSystemPrompt ? `\n\nAdditional instructions:\n${flags.appendSystemPrompt}` : ''}`,
785
993
  systemOverride: flags.systemPrompt || null,
786
994
  });
@@ -793,6 +1001,7 @@ async function session({ flags }) {
793
1001
  if (result.context) { lastContext = result.context; sessionState.lastContext = lastContext; }
794
1002
  }
795
1003
  if (result?.stopReason === 'interrupted') {
1004
+ lastRunInterrupted = true;
796
1005
  ledger.note('interrupted', 'The user interrupted the previous turn with Esc before it finished. Whatever it was doing did not complete.');
797
1006
  // Keep the partial assistant turn out of history if it never completed.
798
1007
  const last = messages[messages.length - 1];
@@ -816,9 +1025,17 @@ async function session({ flags }) {
816
1025
  // conversation the API will reject — but the model is told it happened. Without
817
1026
  // that, its next turn begins as though nothing went wrong and it re-attempts the
818
1027
  // thing that just failed.
819
- 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.`);
820
- 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
+ }
821
1035
  } finally {
1036
+ // Whatever ended the run, the history leaves here well-formed: every tool call
1037
+ // has a result.
1038
+ repairToolPairs(messages);
822
1039
  activeAbort = null;
823
1040
  sessionState.messages = messages;
824
1041
  sessionState.model = model;
@@ -885,12 +1102,16 @@ async function session({ flags }) {
885
1102
  const handleLineInner = async (rawLine) => {
886
1103
  const input = rawLine.trim();
887
1104
  if (!input) return;
1105
+ appendHistoryLine(tui.lastTyped?.() ?? input);
888
1106
  // First submit leaves home: the composer goes full session width.
889
1107
  tui.setHome?.(false);
890
1108
 
891
1109
  // Echo the submission as a first-class entry rather than two pre-formatted lines,
892
1110
  // so it wraps, re-wraps on resize, and carries its attachments.
893
- 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
+ }
894
1115
  else {
895
1116
  const bar = c.brand(glyph('│', '|'));
896
1117
  tui.print(`\n ${bar} ${c.bold(input.replace(/\n/g, ' '))}`, 'user');
@@ -905,6 +1126,10 @@ async function session({ flags }) {
905
1126
  if (input.startsWith('/')) {
906
1127
  const [verb, ...args] = input.split(/\s+/);
907
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);
908
1133
  try {
909
1134
  result = await slashCommand(verb, args, {
910
1135
  client, messages, permissions, flags, spent, resetContext,
@@ -913,15 +1138,27 @@ async function session({ flags }) {
913
1138
  sessionState.model = m;
914
1139
  tui?.setMeta?.({ model: m });
915
1140
  },
916
- skills, sessionState, runManaged, tui, pendingImages, journal, setIntent,
1141
+ skills, sessionState, runManaged, runSide, abortable, tui, pendingImages, journal, setIntent,
917
1142
  });
918
1143
  } catch (error) {
919
1144
  // Slash commands run inside the fullscreen app. Letting this escape to the
920
1145
  // process-level handler prints to stderr outside the alternate screen and
921
1146
  // makes a real failure look like nothing happened.
922
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);
923
1160
  }
924
- if (result === 'exit') { setTuiSink(null); tui.stop(); process.exit(0); }
1161
+ await drainQueued();
925
1162
  return;
926
1163
  }
927
1164
 
@@ -988,9 +1225,17 @@ async function session({ flags }) {
988
1225
  for (const note of mentioned.notes) tui.notice?.(note, 'dim');
989
1226
  }
990
1227
 
991
- // 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.
992
1230
  const pasted = tui.drainAttachments ? tui.drainAttachments(input) : [];
993
- 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];
994
1239
  if (queuedImages.length || mentioned.blocks.length) {
995
1240
  messages.push({ role: 'user', content: [...queuedImages, ...mentioned.blocks, { type: 'text', text: input }] });
996
1241
  } else {
@@ -1006,26 +1251,34 @@ async function session({ flags }) {
1006
1251
  reportAgentError(error, { title: 'That turn did not run' });
1007
1252
  } finally {
1008
1253
  tui.setBusy(false);
1009
- // While the agent was running, Enter queued whatever was typed — send those
1010
- // now, one at a time, each itself interruptible.
1011
- for (;;) {
1012
- const queued = tui.takeQueued()[0];
1013
- if (queued === undefined) break;
1014
- messages.push({ role: 'user', content: queued });
1015
- // Through the same entry type as a typed message. This was two pre-formatted
1016
- // lines each carrying a `│`, pushed in as raw text — which is where the doubled
1017
- // bar people saw after interrupting came from: the queue drains in the `finally`
1018
- // of an interrupted turn, so that is exactly when it showed up.
1019
- if (tui.user) tui.user(queued);
1020
- else tui.print(`\n ${c.brand(glyph('│', '|'))} ${c.bold(queued.replace(/\n/g, ' '))}`, 'user');
1021
- tui.setBusy(true);
1022
- await runManaged(messages);
1023
- tui.setBusy(false);
1024
- }
1025
1254
  await runHook(flags.cwd, 'afterTurn', { prompt: input, sessionId: sessionState.id });
1255
+ await drainQueued();
1026
1256
  }
1027
1257
  };
1028
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
+
1029
1282
  useHistoryFile(HISTORY);
1030
1283
  // ── Which renderer ──
1031
1284
  //
@@ -1054,11 +1307,58 @@ async function session({ flags }) {
1054
1307
  // is incapable.
1055
1308
  : !device.vt && !knownGoodTerminal && process.stdout.isTTY ? false
1056
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
+ }
1057
1338
  if (process.stdout.isTTY && wantFullscreen) {
1058
1339
  tui.start({ onLine: handleLine, history: sessionHistory() });
1059
1340
  // A global install never updates itself; say so when there is something newer.
1060
1341
  // Background and cached for a day — the app never waits on the network for this.
1061
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(() => {});
1062
1362
  // Fresh sessions open on the home moment, opencode-style: the centered
1063
1363
  // two-tone mark, then a tip — the composer below is the whole welcome.
1064
1364
  // The mark is vertically centered in the free space above the pinned
@@ -1246,17 +1546,42 @@ function completeFiles(frag) {
1246
1546
  let currentCwd = process.cwd();
1247
1547
 
1248
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
+
1249
1562
  function sessionHistory() {
1250
1563
  try {
1251
1564
  const file = path.join(HOME, 'history');
1252
1565
  if (!fs.existsSync(file)) return [];
1253
- const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
1254
- 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));
1255
1567
  } catch {
1256
1568
  return [];
1257
1569
  }
1258
1570
  }
1259
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
+
1260
1585
  /** Append one line to the project memory file, creating it with a header if needed. */
1261
1586
  function appendMemory(cwd, note) {
1262
1587
  const file = path.join(cwd, CONTEXT_FILE);
@@ -1312,11 +1637,14 @@ async function relaunchRenderer(ctx, want) {
1312
1637
  setTuiSink(null);
1313
1638
  ctx.tui.stop();
1314
1639
  const bin = fileURLToPath(new URL('../bin/jaitus.js', import.meta.url));
1315
- const argv = process.argv.slice(2).filter((a) => a !== '--resume' && a !== '-r' && a !== '--continue' && a !== '-c');
1316
- // 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);
1317
1644
  const cleaned = [];
1318
1645
  for (let i = 0; i < argv.length; i++) {
1319
- 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;
1320
1648
  cleaned.push(argv[i]);
1321
1649
  }
1322
1650
  out(c.dim(` Relaunching in ${want} mode — conversation continues where it left off…`));
@@ -1328,9 +1656,18 @@ async function relaunchRenderer(ctx, want) {
1328
1656
  * Single-select picker for anything listable (sessions, models, modes, jobs):
1329
1657
  * arrows + Enter, 1-9 jump, Esc cancels. Returns the value or undefined.
1330
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
+
1331
1668
  async function pickOption(tui, title, question, options, preview = []) {
1332
1669
  if (!options.length) return undefined;
1333
- 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 });
1334
1671
  const value = r && typeof r === 'object' ? r.value : r;
1335
1672
  return value == null ? undefined : value;
1336
1673
  }
@@ -1403,20 +1740,39 @@ async function slashCommand(verb, args, ctx) {
1403
1740
  }
1404
1741
 
1405
1742
  case '/export': {
1406
- 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`;
1407
1744
  try {
1408
- const lines = [`# Jaitus session — ${new Date().toISOString()}`, ''];
1409
- for (const m of ctx.messages) {
1410
- if (m.role === 'user' && typeof m.content === 'string') lines.push(`## You\n\n${m.content}\n`);
1411
- else if (m.role === 'assistant') {
1412
- const text = (Array.isArray(m.content) ? m.content : [])
1413
- .filter((b) => b.type === 'text').map((b) => b.text).join('\n');
1414
- const tools = (Array.isArray(m.content) ? m.content : []).filter((b) => b.type === 'tool_use');
1415
- if (text) lines.push(`## Jaitus\n\n${text}\n`);
1416
- 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
+ }
1417
1772
  }
1773
+ body = lines.join('\n');
1418
1774
  }
1419
- fs.writeFileSync(path.resolve(ctx.flags.cwd, target), lines.join('\n'), 'utf8');
1775
+ fs.writeFileSync(path.resolve(ctx.flags.cwd, target), body, 'utf8');
1420
1776
  ok(`Exported to ${c.bold(target)}`);
1421
1777
  } catch (e) {
1422
1778
  fail(`Export failed: ${e.message}`);
@@ -1438,22 +1794,32 @@ async function slashCommand(verb, args, ctx) {
1438
1794
  case '/image': {
1439
1795
  const [p] = args;
1440
1796
  if (!p) { warn('Give an image path: /image screenshot.png (png, jpg, gif, webp — max ~3 MB).'); return null; }
1441
- const abs = path.isAbsolute(p) ? p : path.join(ctx.flags.cwd, p);
1442
- const block = await imageBlock(abs);
1443
- 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; }
1444
1805
  ctx.pendingImages.push(block);
1445
1806
  ok(`Image attached (${Math.round((block.source.data.length * 3) / 4 / 1024)} KB). It is sent with your next message.`);
1446
1807
  return null;
1447
1808
  }
1448
1809
 
1449
1810
  case '/diff': {
1450
- const [stat, diff] = await Promise.all([
1451
- captureProcess('git', ['diff', '--stat'], ctx.flags.cwd),
1452
- 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),
1453
1818
  ]);
1454
1819
  if (stat.error || diff.error) { warn('Not a git repository, or git is not installed.'); return null; }
1455
- const body = `${stat.stdout}${stat.stdout && diff.stdout ? '\n\n' : ''}${diff.stdout}`.trim()
1456
- || '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.';
1457
1823
  if (ctx.tui) await ctx.tui.showPanel('Changes', body.split('\n'));
1458
1824
  else out(body);
1459
1825
  return null;
@@ -1533,7 +1899,12 @@ async function slashCommand(verb, args, ctx) {
1533
1899
  }
1534
1900
  const rule = args.slice(1).join(' ').trim();
1535
1901
  if (!rule) { warn(`Give a rule: /permissions ${next} run_command(npm test:*)`); return null; }
1536
- 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
+ }
1537
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; }
1538
1909
  ctx.permissions.addRule(rule, { persist: true, kind: next });
1539
1910
  ok(`${next === 'deny' ? 'Denied' : 'Allowed'}: ${c.bold(rule)} — saved for this project.`);
@@ -1630,7 +2001,7 @@ async function slashCommand(verb, args, ctx) {
1630
2001
  info(`Pick one: ${c.bold('/resume <id>')} — ${all.slice(0, 9).map((s) => s.id.slice(0, 8)).join(' ')}`);
1631
2002
  return null;
1632
2003
  }
1633
- const all = listSessions({ cwd: ctx.flags.cwd }).slice(0, 9);
2004
+ const all = listSessions({ cwd: ctx.flags.cwd }).slice(0, 20);
1634
2005
  if (!all.length) { info('No saved sessions in this directory yet.'); return null; }
1635
2006
  const chosen = await pickOption(ctx.tui, 'Resume session',
1636
2007
  'Load a saved session from this directory?',
@@ -1707,6 +2078,12 @@ async function slashCommand(verb, args, ctx) {
1707
2078
  }
1708
2079
 
1709
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
+ }
1710
2087
  const { loadSettings: loadSv, saveSettings: saveSv } = await import('./config.js');
1711
2088
  const on = loadSv().editorMode !== 'vim';
1712
2089
  saveSv({ ...loadSv(), editorMode: on ? 'vim' : 'emacs' });
@@ -1816,10 +2193,9 @@ async function slashCommand(verb, args, ctx) {
1816
2193
  const prompt = args.join(' ').trim();
1817
2194
  if (!prompt) { info('Usage: /agent inspect the authentication flow'); return null; }
1818
2195
  const sideMessages = [{ role: 'user', content: prompt }];
1819
- const sidePermissions = createPermissions({ mode: 'plan' });
1820
- const side = await runAgent({
1821
- client: ctx.client, messages: sideMessages, model: ctx.model, cwd: ctx.flags.cwd,
1822
- 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.`,
1823
2199
  });
1824
2200
  info(`Side-agent finished (${side.tools || 0} read/tool calls).`);
1825
2201
  return null;
@@ -1851,7 +2227,14 @@ async function slashCommand(verb, args, ctx) {
1851
2227
 
1852
2228
  case '/code':
1853
2229
  ctx.setIntent('build');
1854
- 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
+ }
1855
2238
  return null;
1856
2239
 
1857
2240
  case '/explain':
@@ -1892,9 +2275,8 @@ async function slashCommand(verb, args, ctx) {
1892
2275
  const question = args.join(' ').trim();
1893
2276
  if (!question) { info('Usage: /btw <quick question> — answered off the record.'); return null; }
1894
2277
  const sideMessages = [{ role: 'user', content: question }];
1895
- await runAgent({
1896
- client: ctx.client, messages: sideMessages, model: ctx.model, cwd: ctx.flags.cwd,
1897
- permissions: createPermissions({ mode: ctx.permissions?.mode || 'default' }),
2278
+ await ctx.runSide({
2279
+ messages: sideMessages, mode: ctx.permissions?.mode || 'default',
1898
2280
  extraSystem: `${skillCatalog(ctx.skills)}\n\nYou are answering a quick side question. Be brief; this answer is shown once and never stored.`,
1899
2281
  });
1900
2282
  info(c.dim('Off the record — the conversation above is untouched.'));
@@ -1943,6 +2325,10 @@ async function slashCommand(verb, args, ctx) {
1943
2325
  if (!outcome.ok) { fail(`Rewind failed: ${outcome.error}`); return null; }
1944
2326
  ctx.sessionState.messages = ctx.messages;
1945
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);
1946
2332
  ok(`Rewound to turn ${index + 1} — ${outcome.messageCount} message${outcome.messageCount === 1 ? '' : 's'} restored`
1947
2333
  + (outcome.restored.length || outcome.deleted.length
1948
2334
  ? c.dim(` · ${outcome.restored.length} file${outcome.restored.length === 1 ? '' : 's'} reverted${outcome.deleted.length ? `, ${outcome.deleted.length} removed` : ''}`)
@@ -1991,7 +2377,8 @@ async function slashCommand(verb, args, ctx) {
1991
2377
  try {
1992
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`;
1993
2379
  fs.appendFileSync(path.join(HOME, 'feedback.log'), line, 'utf8');
1994
- 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.')}`);
1995
2382
  } catch (e) {
1996
2383
  fail(`Could not save feedback: ${e.message}`);
1997
2384
  }
@@ -2031,11 +2418,11 @@ async function slashCommand(verb, args, ctx) {
2031
2418
  return null;
2032
2419
  }
2033
2420
 
2034
- case '/logout': {
2035
- const { default: logout } = await import('./commands/logout.js');
2036
- await logout();
2037
- return 'exit';
2038
- }
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';
2039
2426
 
2040
2427
  case '/doctor': {
2041
2428
  const { default: doctor } = await import('./commands/doctor.js');
@@ -2091,10 +2478,18 @@ async function slashCommand(verb, args, ctx) {
2091
2478
 
2092
2479
  case '/undo': {
2093
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')));
2094
2485
  let i = messages.length - 1;
2095
- while (i >= 0 && !(messages[i].role === 'user' && typeof messages[i].content === 'string')) i--;
2096
- if (i >= 0) { messages.length = i; ctx.resetContext?.(); ok('Dropped the last exchange.'); }
2097
- 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.');
2098
2493
  return null;
2099
2494
  }
2100
2495
 
@@ -2116,7 +2511,7 @@ async function slashCommand(verb, args, ctx) {
2116
2511
  if (!available.length) { warn('The gateway returned no models.'); return null; }
2117
2512
  if (!ctx.tui) { info(`Model: ${c.bold(ctx.model)}`); return null; }
2118
2513
  const picked = await pickOption(ctx.tui, 'Model', 'Which model serves this session?',
2119
- 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}` })));
2120
2515
  if (!picked) return null;
2121
2516
  applyModel(picked);
2122
2517
  return null;
@@ -2273,23 +2668,37 @@ const commands = {
2273
2668
  summary: 'list or configure MCP tool servers',
2274
2669
  usage: [
2275
2670
  'jaitus mcp list',
2276
- 'jaitus mcp add <name> <command> [args...]',
2671
+ 'jaitus mcp add [--global] <name> -- <command> [args...]',
2672
+ 'jaitus mcp trust <name>',
2277
2673
  'jaitus mcp remove <name>',
2278
2674
  ],
2279
- 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
+ ],
2280
2680
  run: (argv, { json, flags }) => {
2281
- 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;
2282
2684
  if (action === 'list') {
2283
2685
  const servers = listMcpServers(flags.cwd);
2284
2686
  present(json, { servers }, () => {
2285
2687
  if (!servers.length) { info('No MCP servers configured.'); return; }
2286
- 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}`));
2287
2689
  });
2288
2690
  return 0;
2289
2691
  }
2290
2692
  if (action === 'add' && name && command) {
2291
- addMcpServer(flags.cwd, name, { type: 'stdio', command, args });
2292
- 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(' ')}`);
2293
2702
  return 0;
2294
2703
  }
2295
2704
  if (action === 'remove' && name) {
@@ -2297,7 +2706,7 @@ const commands = {
2297
2706
  ok(`Removed MCP server ${c.bold(name)}.`);
2298
2707
  return 0;
2299
2708
  }
2300
- 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>');
2301
2710
  return 1;
2302
2711
  },
2303
2712
  },
@@ -2308,7 +2717,8 @@ const commands = {
2308
2717
  'jaitus config show everything',
2309
2718
  'jaitus config model show one value',
2310
2719
  'jaitus config model <id> set it',
2311
- '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',
2312
2722
  'jaitus config copyOnSelect <on|off> auto-copy mouse selection',
2313
2723
  ],
2314
2724
  detail: [
@@ -2321,7 +2731,11 @@ const commands = {
2321
2731
  'Stack Overflow directly, which covers most of what a coding agent asks.',
2322
2732
  ],
2323
2733
  run: (argv, { json }) => {
2324
- 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();
2325
2739
  const settings = loadSettings();
2326
2740
  const ONOFF = ['copyOnSelect', 'wheelAccel', 'autoScroll', 'animations', 'autoCompact', 'mouse', 'autoUpdate'];
2327
2741
  const SECRET = ['braveSearchKey', 'tavilyKey', 'serperKey'];
@@ -2349,7 +2763,7 @@ const commands = {
2349
2763
  ['Gateway', data.gateway],
2350
2764
  ['Website', data.website],
2351
2765
  ['Model', data.model || 'whatever the gateway lists first'],
2352
- ['Theme', `${data.theme} (dark, light, daltonized, plain, auto)`],
2766
+ ['Theme', `${data.theme} (${[...THEME_NAMES, 'auto'].join(', ')})`],
2353
2767
  ['Copy on select', data.copyOnSelect ? 'on (drag selects, auto-copies)' : 'off (Ctrl+Shift+C copies)'],
2354
2768
  ['Scroll speed', String(data.scrollSpeed)],
2355
2769
  ['Wheel accel', data.wheelAccel ? 'on' : 'off'],
@@ -2367,6 +2781,14 @@ const commands = {
2367
2781
  fail(`Unknown setting: ${key}. Try model, baseUrl, siteUrl, theme, statusLine, ${ONOFF.join(', ')}, ${SECRET.join(', ')} or scrollSpeed.`);
2368
2782
  return 1;
2369
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
+ }
2370
2792
  if (!value) {
2371
2793
  // Never print a key back. Whether it is set, and how long it is, answers every
2372
2794
  // question anyone actually has about it.
@@ -2378,7 +2800,9 @@ const commands = {
2378
2800
  out(String(settings[key] ?? '')); return 0;
2379
2801
  }
2380
2802
  if (key === 'theme') {
2381
- 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; }
2382
2806
  settings.theme = value;
2383
2807
  saveSettings(settings);
2384
2808
  ok(`theme = ${value}`);
@@ -2432,7 +2856,7 @@ const commands = {
2432
2856
  update: {
2433
2857
  summary: 'check whether a newer version is published',
2434
2858
  usage: ['jaitus update [--json]'],
2435
- 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`.'],
2436
2860
  run: (argv, { json }) => update(argv, { json }),
2437
2861
  },
2438
2862
 
@@ -2490,13 +2914,41 @@ export default async function main(argv) {
2490
2914
  const { flags, rest, passthrough } = parseArgs(argv);
2491
2915
 
2492
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
+ }
2493
2935
 
2494
2936
  // Always on the newest version: install it and reopen before doing anything else.
2495
2937
  const relaunched = await autoUpdate({ argv, settings: loadSettings() }).catch(() => null);
2496
2938
  if (relaunched !== null) return relaunched;
2497
2939
 
2498
2940
  const [first, ...others] = rest;
2499
- 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
+ }
2500
2952
 
2501
2953
  if (flags.help) {
2502
2954
  if (cmd) return commandHelp(first);
@@ -2520,8 +2972,8 @@ export default async function main(argv) {
2520
2972
  return 1;
2521
2973
  }
2522
2974
 
2523
- const piped = await readStdin();
2524
2975
  const prompt = rest.join(' ').trim();
2976
+ const piped = await readStdin({ patient: Boolean(prompt) || flags.print });
2525
2977
 
2526
2978
  if (prompt) {
2527
2979
  const task = piped ? `${prompt}\n\n--- piped input ---\n${piped}` : prompt;