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/README.md +34 -38
- package/bin/jaitus.js +14 -1
- package/package.json +2 -2
- package/src/agent/loop.js +234 -35
- 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/commands/update.js +78 -12
- package/src/config.js +12 -1
- package/src/errors.js +4 -0
- package/src/index.js +571 -119
- package/src/mcp.js +209 -72
- package/src/pendingUpdate.js +41 -0
- 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 +161 -0
- 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/widgets.js +6 -2
- package/src/tui-ink.js +2509 -1998
- 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,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:
|
|
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
|
-
|
|
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,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
|
-
|
|
820
|
-
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
|
+
}
|
|
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)
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1316
|
-
//
|
|
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 (
|
|
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,
|
|
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[
|
|
1743
|
+
const target = args.join(' ').replace(/^["']|["']$/g, '') || `jaitus-session-${new Date().toISOString().slice(0, 16).replace(/[:T]/g, '-')}.md`;
|
|
1407
1744
|
try {
|
|
1408
|
-
const
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
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),
|
|
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
|
-
|
|
1442
|
-
const
|
|
1443
|
-
|
|
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
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
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
|
|
1456
|
-
|
|
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') {
|
|
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,
|
|
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
|
|
1820
|
-
|
|
1821
|
-
|
|
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
|
-
|
|
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
|
|
1896
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
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]
|
|
2096
|
-
if (i >= 0) {
|
|
2097
|
-
|
|
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,
|
|
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: [
|
|
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
|
|
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.
|
|
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,
|
|
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
|
-
|
|
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} (
|
|
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
|
-
|
|
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: ['
|
|
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
|
-
|
|
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;
|