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/README.md +33 -37
- package/bin/jaitus.js +8 -0
- package/package.json +2 -2
- package/src/agent/loop.js +186 -28
- package/src/agent/permissions.js +50 -17
- package/src/agent/rules.js +85 -14
- package/src/agent/tools.js +266 -50
- package/src/background.js +34 -7
- package/src/commands/commit.js +63 -46
- package/src/commands/doctor.js +1 -1
- package/src/commands/explain.js +5 -5
- package/src/commands/logout.js +2 -0
- package/src/commands/research.js +5 -6
- package/src/commands/review.js +8 -11
- package/src/commands/test.js +18 -14
- package/src/config.js +12 -1
- package/src/errors.js +4 -0
- package/src/index.js +567 -118
- package/src/mcp.js +209 -72
- package/src/project.js +0 -0
- package/src/session.js +18 -0
- package/src/sessions.js +20 -2
- package/src/terminal.js +46 -11
- package/src/tui/ansi.js +3 -1
- package/src/tui/clipboard.js +100 -7
- package/src/tui/entries.js +5 -5
- package/src/tui/highlight.js +4 -4
- package/src/tui/markdown.js +1 -1
- package/src/tui/theme.js +8 -0
- package/src/tui-ink.js +493 -103
- package/src/tui.js +4 -0
- package/src/ui.js +5 -1
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 {
|
|
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
|
-
|
|
101
|
-
else if (a === '--
|
|
102
|
-
else if (a === '--
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
431
|
-
if (format !== 'text') return
|
|
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: '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
823
|
-
if (messages[messages.length - 1]?.role === 'user')
|
|
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)
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1319
|
-
//
|
|
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 (
|
|
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,
|
|
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[
|
|
1743
|
+
const target = args.join(' ').replace(/^["']|["']$/g, '') || `jaitus-session-${new Date().toISOString().slice(0, 16).replace(/[:T]/g, '-')}.md`;
|
|
1410
1744
|
try {
|
|
1411
|
-
const
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
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),
|
|
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
|
-
|
|
1445
|
-
const
|
|
1446
|
-
|
|
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
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
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
|
|
1459
|
-
|
|
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') {
|
|
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,
|
|
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
|
|
1823
|
-
|
|
1824
|
-
|
|
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
|
-
|
|
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
|
|
1899
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
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]
|
|
2099
|
-
if (i >= 0) {
|
|
2100
|
-
|
|
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,
|
|
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: [
|
|
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
|
|
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.
|
|
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,
|
|
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
|
-
|
|
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} (
|
|
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
|
-
|
|
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: ['
|
|
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
|
-
|
|
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;
|