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.
Files changed (3) hide show
  1. package/index.js +81 -26
  2. package/lib/loop.js +24 -17
  3. 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.10.0';
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
- // Builds the colored mode badges shown below the version line.
516
- let _effortLevel = 'medium'; // default
533
+ let _effortLevel = 'medium';
534
+
517
535
  function updateModeLabel() {
518
536
  const badges = [];
519
- if (planMode) badges.push(yellow('[plan]'));
520
- const effortColors = {
521
- low: green,
522
- medium: dim, // medium is default — show dimmed so it's not noise
523
- high: red,
524
- max: magenta,
525
- };
526
- if (_effortLevel !== 'medium') {
527
- const color = effortColors[_effortLevel] || dim;
528
- badges.push(color(`[${_effortLevel} effort]`));
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
- planMode = !planMode;
756
- if (planMode) console.log(yellow(' Plan mode ON — read-only, no writes or execution.\n'));
757
- else console.log(green(' Plan mode OFF full capabilities restored.\n'));
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
- const highlight = createHighlighter((s) => process.stdout.write(s));
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
- // Plan mode: block Write, Edit, Bash
440
- if (sessionInfo?.planMode && ['Write', 'Edit', 'Bash'].includes(tc.name)) {
441
- results.push(`[${tc.name} result]\n[PLAN MODE] ${tc.name} is disabled. Describe what you would do instead.`);
442
- onToolCall(tc.name, typeof tc.arguments === 'string' ? tc.arguments : JSON.stringify(tc.arguments));
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 and surface the question to the user
451
+ // ── AskUser: always intercept regardless of mode ─────────────────
447
452
  if (tc.name === 'AskUser' && confirmCallback) {
448
- const parsed = typeof tc.arguments === 'string' ? JSON.parse(tc.arguments) : tc.arguments;
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
- // Dangerous command confirmation
461
- if (tc.name === 'Bash' && confirmCallback) {
462
- const parsed = typeof tc.arguments === 'string' ? JSON.parse(tc.arguments) : tc.arguments;
463
- const normalized = normalizeArgs(parsed);
464
- if (isDangerous(normalized.command)) {
465
- const ok = await confirmCallback(normalized.command);
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
- onToolCall(tc.name, typeof tc.arguments === 'string' ? tc.arguments : JSON.stringify(tc.arguments));
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tsunami-code",
3
- "version": "3.11.3",
3
+ "version": "3.11.5",
4
4
  "description": "Tsunami Code CLI — AI coding agent by Keystone World Management Navy Seal Unit XI3",
5
5
  "type": "module",
6
6
  "bin": {