tsunami-code 3.11.3 → 3.11.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/index.js +81 -26
- package/lib/loop.js +24 -17
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
} from './lib/memory.js';
|
|
27
27
|
import { listMemories, readMemory, saveMemory, deleteMemory, getMemdirPath } from './lib/memdir.js';
|
|
28
28
|
|
|
29
|
-
const VERSION = '3.
|
|
29
|
+
const VERSION = '3.11.5';
|
|
30
30
|
const CONFIG_DIR = join(os.homedir(), '.tsunami-code');
|
|
31
31
|
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
|
32
32
|
const DEFAULT_SERVER = 'https://radiometric-reita-amuck.ngrok-free.dev';
|
|
@@ -166,6 +166,20 @@ function createHighlighter(write) {
|
|
|
166
166
|
renderLine(buf.slice(0, nl));
|
|
167
167
|
buf = buf.slice(nl + 1);
|
|
168
168
|
}
|
|
169
|
+
// Flush partial line at word boundaries so text streams word-by-word
|
|
170
|
+
// (skip inside code fences where exact formatting matters)
|
|
171
|
+
if (!inFence && buf.length > 0) {
|
|
172
|
+
const lastSpace = buf.lastIndexOf(' ');
|
|
173
|
+
if (lastSpace > 0) {
|
|
174
|
+
const partial = buf.slice(0, lastSpace + 1);
|
|
175
|
+
const styled = partial
|
|
176
|
+
.replace(/\*\*([^*\n]+)\*\*/g, (_, t) => chalk.bold(t))
|
|
177
|
+
.replace(/__([^_\n]+)__/g, (_, t) => chalk.bold(t))
|
|
178
|
+
.replace(/`([^`\n]+)`/g, (_, t) => chalk.yellow('`' + t + '`'));
|
|
179
|
+
write(styled);
|
|
180
|
+
buf = buf.slice(lastSpace + 1);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
169
183
|
};
|
|
170
184
|
}
|
|
171
185
|
|
|
@@ -470,7 +484,7 @@ async function run() {
|
|
|
470
484
|
// Slash command list for tab completion — keep in sync with switch cases + skills
|
|
471
485
|
const SLASH_COMMANDS = [
|
|
472
486
|
'/help', '/compact', '/plan', '/undo', '/doctor', '/cost', '/memory', '/clear',
|
|
473
|
-
'/status', '/server', '/model', '/mcp', '/effort', '/copy', '/btw', '/rewind',
|
|
487
|
+
'/status', '/server', '/model', '/mcp', '/effort', '/mode', '/copy', '/btw', '/rewind',
|
|
474
488
|
'/diff', '/stats', '/export', '/history', '/exit', '/quit', '/kairos', '/skills',
|
|
475
489
|
'/skill-create', '/skill-list', '/init', '/memdir',
|
|
476
490
|
];
|
|
@@ -511,22 +525,35 @@ async function run() {
|
|
|
511
525
|
ui.start(historyEntries);
|
|
512
526
|
ui.setModelLabel(`Tsunami Code CLI v${VERSION}`);
|
|
513
527
|
|
|
528
|
+
// ── Permission mode ───────────────────────────────────────────────────────
|
|
529
|
+
const PERM_MODES = ['auto', 'accept-edits', 'confirm-writes', 'confirm-all', 'readonly', 'bypass'];
|
|
530
|
+
let permMode = 'auto';
|
|
531
|
+
|
|
514
532
|
// ── Mode label helper ─────────────────────────────────────────────────────
|
|
515
|
-
|
|
516
|
-
|
|
533
|
+
let _effortLevel = 'medium';
|
|
534
|
+
|
|
517
535
|
function updateModeLabel() {
|
|
518
536
|
const badges = [];
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
537
|
+
|
|
538
|
+
// Permission mode badge (only show if not default 'auto')
|
|
539
|
+
const permBadge = {
|
|
540
|
+
'auto': null,
|
|
541
|
+
'accept-edits': dim('[accept-edits]'),
|
|
542
|
+
'confirm-writes': cyan('[confirm-writes]'),
|
|
543
|
+
'confirm-all': yellow('[confirm-all]'),
|
|
544
|
+
'readonly': chalk.bgBlue.white(' readonly '),
|
|
545
|
+
'bypass': chalk.bgRed.white.bold(' bypass '),
|
|
546
|
+
}[permMode];
|
|
547
|
+
if (permBadge) badges.push(permBadge);
|
|
548
|
+
|
|
549
|
+
// Effort badge (only show if not default 'medium')
|
|
550
|
+
const effortBadge = {
|
|
551
|
+
low: green('[low effort]'),
|
|
552
|
+
high: red('[high effort]'),
|
|
553
|
+
max: magenta('[max effort]'),
|
|
554
|
+
}[_effortLevel];
|
|
555
|
+
if (effortBadge) badges.push(effortBadge);
|
|
556
|
+
|
|
530
557
|
ui.setModeLabel(badges.join(' '));
|
|
531
558
|
}
|
|
532
559
|
|
|
@@ -701,7 +728,7 @@ async function run() {
|
|
|
701
728
|
if (firstToken) { process.stdout.write(' '); firstToken = false; }
|
|
702
729
|
process.stdout.write(token);
|
|
703
730
|
}, (name, args) => { printToolCall(name, args); firstToken = true; },
|
|
704
|
-
{ sessionDir, cwd, planMode }, makeConfirmCallback(ui));
|
|
731
|
+
{ sessionDir, cwd, planMode, permMode }, makeConfirmCallback(ui));
|
|
705
732
|
process.stdout.write('\n\n');
|
|
706
733
|
} catch(e) { console.error(red(` Error: ${e.message}\n`)); }
|
|
707
734
|
isProcessing = false; ui.resume();
|
|
@@ -729,6 +756,7 @@ async function run() {
|
|
|
729
756
|
['/model [name]', 'Show or change active model (default: local)'],
|
|
730
757
|
['/mcp', 'Show MCP server status and tools'],
|
|
731
758
|
['/effort <level>', 'Set reasoning effort: low/medium/high/max'],
|
|
759
|
+
['/mode [name]', 'Set permission mode: auto/accept-edits/confirm-writes/confirm-all/readonly/bypass'],
|
|
732
760
|
['/copy', 'Copy last response to clipboard'],
|
|
733
761
|
['/btw <note>', 'Inject a note into conversation without a response'],
|
|
734
762
|
['/rewind', 'Remove last user+assistant exchange'],
|
|
@@ -752,12 +780,35 @@ async function run() {
|
|
|
752
780
|
await compactMessages(rest.join(' '));
|
|
753
781
|
break;
|
|
754
782
|
case 'plan':
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
783
|
+
permMode = permMode === 'readonly' ? 'auto' : 'readonly';
|
|
784
|
+
planMode = permMode === 'readonly';
|
|
785
|
+
if (planMode) console.log(yellow(' Plan mode ON → readonly.\n'));
|
|
786
|
+
else console.log(green(' Plan mode OFF → auto.\n'));
|
|
758
787
|
ui.setPlanMode(planMode);
|
|
759
788
|
updateModeLabel();
|
|
760
789
|
break;
|
|
790
|
+
case 'mode': {
|
|
791
|
+
const m = rest[0]?.toLowerCase();
|
|
792
|
+
if (!m) {
|
|
793
|
+
console.log(blue('\n Permission modes:\n'));
|
|
794
|
+
for (const pm of PERM_MODES) {
|
|
795
|
+
const active = pm === permMode ? green(' ← active') : '';
|
|
796
|
+
console.log(` ${cyan(pm.padEnd(16))}${active}`);
|
|
797
|
+
}
|
|
798
|
+
console.log(dim('\n Usage: /mode <name>\n'));
|
|
799
|
+
break;
|
|
800
|
+
}
|
|
801
|
+
if (!PERM_MODES.includes(m)) {
|
|
802
|
+
console.log(red(` Unknown mode: ${m}\n Options: ${PERM_MODES.join(', ')}\n`));
|
|
803
|
+
break;
|
|
804
|
+
}
|
|
805
|
+
permMode = m;
|
|
806
|
+
planMode = m === 'readonly';
|
|
807
|
+
ui.setPlanMode(planMode);
|
|
808
|
+
updateModeLabel();
|
|
809
|
+
console.log(green(` Mode: ${m}\n`));
|
|
810
|
+
break;
|
|
811
|
+
}
|
|
761
812
|
case 'undo': {
|
|
762
813
|
const restored = undo();
|
|
763
814
|
if (restored) {
|
|
@@ -1219,18 +1270,22 @@ Output ONLY the TSUNAMI.md content, starting with "# Project: <name>"`;
|
|
|
1219
1270
|
let toolTimers = {}; // track per-tool duration
|
|
1220
1271
|
|
|
1221
1272
|
spinner.start();
|
|
1222
|
-
|
|
1273
|
+
// Spinner stops the moment first text actually reaches the screen,
|
|
1274
|
+
// not on first raw token (highlighter buffers until word boundary).
|
|
1275
|
+
const highlight = createHighlighter((s) => {
|
|
1276
|
+
if (firstToken) {
|
|
1277
|
+
spinner.stop();
|
|
1278
|
+
process.stdout.write(' ');
|
|
1279
|
+
firstToken = false;
|
|
1280
|
+
}
|
|
1281
|
+
process.stdout.write(s);
|
|
1282
|
+
});
|
|
1223
1283
|
|
|
1224
1284
|
try {
|
|
1225
1285
|
await agentLoop(
|
|
1226
1286
|
currentServerUrl,
|
|
1227
1287
|
fullMessages,
|
|
1228
1288
|
(token) => {
|
|
1229
|
-
if (firstToken) {
|
|
1230
|
-
spinner.stop();
|
|
1231
|
-
process.stdout.write(' ');
|
|
1232
|
-
firstToken = false;
|
|
1233
|
-
}
|
|
1234
1289
|
highlight(token);
|
|
1235
1290
|
},
|
|
1236
1291
|
(toolName, toolArgs) => {
|
|
@@ -1242,7 +1297,7 @@ Output ONLY the TSUNAMI.md content, starting with "# Project: <name>"`;
|
|
|
1242
1297
|
spinner.start();
|
|
1243
1298
|
firstToken = true;
|
|
1244
1299
|
},
|
|
1245
|
-
{ sessionDir, cwd, planMode },
|
|
1300
|
+
{ sessionDir, cwd, planMode, permMode },
|
|
1246
1301
|
makeConfirmCallback(ui)
|
|
1247
1302
|
);
|
|
1248
1303
|
spinner.stop();
|
package/lib/loop.js
CHANGED
|
@@ -436,19 +436,21 @@ export async function agentLoop(serverUrl, messages, onToken, onToolCall, sessio
|
|
|
436
436
|
|
|
437
437
|
const results = [];
|
|
438
438
|
for (const tc of toolCalls) {
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
439
|
+
const permMode = sessionInfo?.permMode || 'auto';
|
|
440
|
+
const argsStr = typeof tc.arguments === 'string' ? tc.arguments : JSON.stringify(tc.arguments);
|
|
441
|
+
const parsed = (() => { try { return typeof tc.arguments === 'string' ? JSON.parse(tc.arguments) : tc.arguments; } catch { return {}; } })();
|
|
442
|
+
const normalized = normalizeArgs(parsed);
|
|
443
|
+
|
|
444
|
+
// ── readonly: block all mutating tools ──────────────────────────
|
|
445
|
+
if (permMode === 'readonly' && ['Write', 'Edit', 'Bash'].includes(tc.name)) {
|
|
446
|
+
onToolCall(tc.name, argsStr);
|
|
447
|
+
results.push(`[${tc.name} result]\n[READONLY MODE] ${tc.name} is disabled. Describe what you would do instead.`);
|
|
443
448
|
continue;
|
|
444
449
|
}
|
|
445
450
|
|
|
446
|
-
// AskUser: intercept
|
|
451
|
+
// ── AskUser: always intercept regardless of mode ─────────────────
|
|
447
452
|
if (tc.name === 'AskUser' && confirmCallback) {
|
|
448
|
-
|
|
449
|
-
const normalized = normalizeArgs(parsed);
|
|
450
|
-
onToolCall(tc.name, typeof tc.arguments === 'string' ? tc.arguments : JSON.stringify(tc.arguments));
|
|
451
|
-
// Reuse confirmCallback channel but pass question back as answer
|
|
453
|
+
onToolCall(tc.name, argsStr);
|
|
452
454
|
const answer = await new Promise(resolve => confirmCallback._askUser
|
|
453
455
|
? confirmCallback._askUser(normalized.question, resolve)
|
|
454
456
|
: resolve('[No answer provided]')
|
|
@@ -457,15 +459,20 @@ export async function agentLoop(serverUrl, messages, onToken, onToolCall, sessio
|
|
|
457
459
|
continue;
|
|
458
460
|
}
|
|
459
461
|
|
|
460
|
-
//
|
|
461
|
-
if (
|
|
462
|
-
const
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
462
|
+
// ── Permission gate (skip entirely in bypass mode) ───────────────
|
|
463
|
+
if (permMode !== 'bypass' && confirmCallback) {
|
|
464
|
+
const needsConfirm =
|
|
465
|
+
(permMode === 'confirm-all' && ['Write', 'Edit', 'Bash'].includes(tc.name)) ||
|
|
466
|
+
(permMode === 'confirm-writes' && ['Write', 'Edit'].includes(tc.name)) ||
|
|
467
|
+
(permMode === 'accept-edits' && tc.name === 'Write') ||
|
|
468
|
+
(tc.name === 'Bash' && isDangerous(normalized.command));
|
|
469
|
+
|
|
470
|
+
if (needsConfirm) {
|
|
471
|
+
const prompt = tc.name === 'Bash' ? normalized.command : `${tc.name}: ${normalized.file_path || ''}`;
|
|
472
|
+
onToolCall(tc.name, argsStr);
|
|
473
|
+
const ok = await confirmCallback(prompt);
|
|
466
474
|
if (!ok) {
|
|
467
|
-
|
|
468
|
-
results.push(`[${tc.name} result]\nCommand blocked by user. Find a safer approach to accomplish this.`);
|
|
475
|
+
results.push(`[${tc.name} result]\nBlocked by user (${permMode} mode). Find an alternative approach.`);
|
|
469
476
|
continue;
|
|
470
477
|
}
|
|
471
478
|
}
|