tsunami-code 3.2.0 → 3.4.0

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 CHANGED
@@ -11,7 +11,8 @@ import { isCoordinatorTask, stripCoordinatorPrefix, buildCoordinatorSystemPrompt
11
11
  import { getDueTasks, markDone, cancelTask, listTasks, formatTaskList } from './lib/kairos.js';
12
12
  import { buildSystemPrompt } from './lib/prompt.js';
13
13
  import { runPreflight, checkServer } from './lib/preflight.js';
14
- import { setSession, undo, undoStackSize } from './lib/tools.js';
14
+ import { setSession, undo, undoStackSize, registerMcpTools, linesChanged } from './lib/tools.js';
15
+ import { connectMcpServers, getMcpToolObjects, getMcpStatus, getMcpConfigPath, disconnectAll as disconnectMcp } from './lib/mcp.js';
15
16
  import {
16
17
  initSession,
17
18
  initProjectMemory,
@@ -24,7 +25,7 @@ import {
24
25
  getSessionContext
25
26
  } from './lib/memory.js';
26
27
 
27
- const VERSION = '3.2.0';
28
+ const VERSION = '3.4.0';
28
29
  const CONFIG_DIR = join(os.homedir(), '.tsunami-code');
29
30
  const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
30
31
  const DEFAULT_SERVER = 'https://radiometric-reita-amuck.ngrok-free.dev';
@@ -171,6 +172,8 @@ async function run() {
171
172
  const cwd = process.cwd();
172
173
  let planMode = argv.includes('--plan');
173
174
 
175
+ const sessionStartTime = Date.now();
176
+
174
177
  // Initialize memory systems
175
178
  const { sessionId, sessionDir } = initSession(cwd);
176
179
  initProjectMemory(cwd);
@@ -225,6 +228,22 @@ async function run() {
225
228
  dim(` · ${sessionId} · Type your task. /help for commands. Ctrl+C to exit.\n`)
226
229
  );
227
230
 
231
+ // MCP: connect servers from ~/.tsunami-code/mcp.json (non-blocking — warnings only)
232
+ {
233
+ const mcpResults = await connectMcpServers();
234
+ if (mcpResults.length > 0) {
235
+ const mcpTools = getMcpToolObjects();
236
+ registerMcpTools(mcpTools);
237
+ const ok = mcpResults.filter(r => !r.error);
238
+ const fail = mcpResults.filter(r => r.error);
239
+ if (ok.length) console.log(green(` ✓ MCP`) + dim(` ${ok.map(r => `${r.name}(${r.toolCount})`).join(', ')}`));
240
+ if (fail.length) {
241
+ for (const f of fail) console.log(yellow(` ⚠ MCP "${f.name}" failed: ${f.error}`));
242
+ }
243
+ if (ok.length || fail.length) console.log();
244
+ }
245
+ }
246
+
228
247
  // KAIROS: run due background tasks on startup
229
248
  const dueTasks = getDueTasks();
230
249
  if (dueTasks.length > 0) {
@@ -318,9 +337,8 @@ async function run() {
318
337
 
319
338
  // ── Exit handler ─────────────────────────────────────────────────────────────
320
339
  function gracefulExit(code = 0) {
321
- try {
322
- endSession(sessionDir);
323
- } catch {}
340
+ try { endSession(sessionDir); } catch {}
341
+ try { disconnectMcp(); } catch {}
324
342
  console.log(dim('\n Goodbye.\n'));
325
343
  process.exit(code);
326
344
  }
@@ -440,7 +458,7 @@ async function run() {
440
458
 
441
459
  // Skills: check if this is a skill command before built-ins
442
460
  const skillMatch = getSkillCommand(skills, line);
443
- if (skillMatch && !['help','compact','plan','undo','doctor','cost','clear','status','server','model','memory','history','exit','quit','skill-create','skill-list','skills'].includes(cmd)) {
461
+ if (skillMatch && !['help','compact','plan','undo','doctor','cost','clear','status','server','model','memory','history','exit','quit','skill-create','skill-list','skills','mcp','copy','btw','rewind','diff','stats','export'].includes(cmd)) {
444
462
  // Run the skill prompt as a user message
445
463
  const userContent = skillMatch.args
446
464
  ? `[Skill: ${skillMatch.skill.name}]\n${skillMatch.prompt}`
@@ -481,6 +499,13 @@ async function run() {
481
499
  ['/status', 'Show context size and server'],
482
500
  ['/server <url>', 'Change model server URL'],
483
501
  ['/model [name]', 'Show or change active model (default: local)'],
502
+ ['/mcp', 'Show MCP server status and tools'],
503
+ ['/copy', 'Copy last response to clipboard'],
504
+ ['/btw <note>', 'Inject a note into conversation without a response'],
505
+ ['/rewind', 'Remove last user+assistant exchange'],
506
+ ['/diff', 'Show git diff of session file changes'],
507
+ ['/stats', 'Session stats: lines, tokens, duration'],
508
+ ['/export [file]', 'Export conversation to markdown file'],
484
509
  ['/history', 'Show recent command history'],
485
510
  ['/exit', 'Exit'],
486
511
  ];
@@ -505,6 +530,25 @@ async function run() {
505
530
  else console.log(dim(' Nothing to undo.\n'));
506
531
  break;
507
532
  }
533
+ case 'mcp': {
534
+ const mcpServers = getMcpStatus();
535
+ if (mcpServers.length === 0) {
536
+ console.log(dim(`\n No MCP servers connected.`));
537
+ console.log(dim(` Add servers to: ${getMcpConfigPath()}`));
538
+ console.log(dim(' Format: { "servers": { "name": { "command": "npx", "args": [...] } } }\n'));
539
+ } else {
540
+ console.log(blue(`\n MCP Servers (${mcpServers.length})\n`));
541
+ for (const srv of mcpServers) {
542
+ console.log(` ${cyan(srv.name)} ${dim(`— ${srv.toolCount} tool${srv.toolCount !== 1 ? 's' : ''}`)}`);
543
+ for (const tool of srv.tools) {
544
+ const desc = tool.description ? dim(` — ${tool.description}`) : '';
545
+ console.log(` ${dim('•')} ${tool.name}${desc}`);
546
+ }
547
+ }
548
+ console.log();
549
+ }
550
+ break;
551
+ }
508
552
  case 'doctor': {
509
553
  const { getRgPath } = await import('./lib/preflight.js');
510
554
  const { getMemoryStats: _getMemoryStats } = await import('./lib/memory.js');
@@ -530,12 +574,20 @@ async function run() {
530
574
  }
531
575
  case 'cost': {
532
576
  const hasReal = tokenStats.requests > 0;
533
- console.log(blue('\n Session Token Usage'));
577
+ console.log(blue('\n Session Token Usage\n'));
534
578
  if (hasReal) {
535
- console.log(dim(` Input : ${tokenStats.input.toLocaleString()} tokens (actual)`));
536
- console.log(dim(` Output : ${tokenStats.output.toLocaleString()} tokens (actual)`));
537
- console.log(dim(` Total : ${(tokenStats.input + tokenStats.output).toLocaleString()} tokens`));
538
- console.log(dim(` Requests: ${tokenStats.requests}`));
579
+ console.log(dim(` Input : ${tokenStats.input.toLocaleString()} tokens`));
580
+ console.log(dim(` Output : ${tokenStats.output.toLocaleString()} tokens`));
581
+ console.log(dim(` Total : ${(tokenStats.input + tokenStats.output).toLocaleString()} tokens`));
582
+ console.log(dim(` Requests : ${tokenStats.requests}`));
583
+ // Per-model breakdown (mirrors cost-tracker.ts formatModelUsage)
584
+ const models = Object.entries(tokenStats.byModel);
585
+ if (models.length > 1) {
586
+ console.log(blue('\n By model:\n'));
587
+ for (const [model, usage] of models) {
588
+ console.log(dim(` ${model.padEnd(20)} ${usage.input.toLocaleString()} in ${usage.output.toLocaleString()} out (${usage.requests} req)`));
589
+ }
590
+ }
539
591
  } else {
540
592
  console.log(dim(` Input : ~${_inputTokens.toLocaleString()} (estimated)`));
541
593
  console.log(dim(` Output : ~${_outputTokens.toLocaleString()} (estimated)`));
@@ -544,6 +596,137 @@ async function run() {
544
596
  console.log();
545
597
  break;
546
598
  }
599
+ case 'copy': {
600
+ // Copy last assistant response to clipboard — from commands.ts copy command
601
+ const lastAss = [...messages].reverse().find(m => m.role === 'assistant');
602
+ if (!lastAss) { console.log(dim(' Nothing to copy.\n')); break; }
603
+ const text = typeof lastAss.content === 'string' ? lastAss.content : JSON.stringify(lastAss.content);
604
+ try {
605
+ const { execSync } = await import('child_process');
606
+ if (process.platform === 'darwin') {
607
+ execSync('pbcopy', { input: text, stdio: ['pipe', 'ignore', 'ignore'] });
608
+ } else if (process.platform === 'win32') {
609
+ execSync('clip', { input: text, stdio: ['pipe', 'ignore', 'ignore'], shell: true });
610
+ } else {
611
+ execSync('xclip -selection clipboard 2>/dev/null || xsel --clipboard --input', { input: text, stdio: ['pipe', 'ignore', 'ignore'], shell: true });
612
+ }
613
+ console.log(green(` ✓ Copied ${text.length.toLocaleString()} chars to clipboard\n`));
614
+ } catch (e) {
615
+ console.log(red(` Copy failed: ${e.message}\n`));
616
+ }
617
+ break;
618
+ }
619
+ case 'btw': {
620
+ // Inject a sticky note into conversation without triggering a model turn
621
+ // From CC's btw command — useful for mid-task context injection
622
+ const note = rest.join(' ');
623
+ if (!note) { console.log(red(' Usage: /btw <note>\n')); break; }
624
+ messages.push({ role: 'user', content: `[btw: ${note}]` });
625
+ messages.push({ role: 'assistant', content: 'Noted.' });
626
+ console.log(green(' ✓ Noted.\n'));
627
+ break;
628
+ }
629
+ case 'rewind': {
630
+ // Remove last user+assistant exchange from context
631
+ // From CC's rewind command
632
+ if (messages.length < 2) { console.log(dim(' Nothing to rewind.\n')); break; }
633
+ // Find last assistant message
634
+ let lastAssIdx = -1;
635
+ for (let i = messages.length - 1; i >= 0; i--) {
636
+ if (messages[i].role === 'assistant') { lastAssIdx = i; break; }
637
+ }
638
+ if (lastAssIdx === -1) { console.log(dim(' Nothing to rewind.\n')); break; }
639
+ // Find the user message that preceded it
640
+ let lastUserIdx = lastAssIdx;
641
+ for (let i = lastAssIdx - 1; i >= 0; i--) {
642
+ if (messages[i].role === 'user') { lastUserIdx = i; break; }
643
+ }
644
+ const removed = messages.splice(lastUserIdx).length;
645
+ console.log(green(` ✓ Rewound ${removed} message(s). Context: ${messages.length} messages.\n`));
646
+ break;
647
+ }
648
+ case 'diff': {
649
+ // Show git diff of file changes made this session
650
+ // From CC's diff command
651
+ try {
652
+ const { execSync: _exec } = await import('child_process');
653
+ const statArgs = rest.includes('--full') ? '' : '--stat';
654
+ let diffOut = '';
655
+ try {
656
+ diffOut = _exec(`git diff HEAD ${statArgs}`, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
657
+ } catch {
658
+ diffOut = _exec(`git diff ${statArgs}`, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
659
+ }
660
+ if (!diffOut.trim()) {
661
+ console.log(dim(' No changes detected. (Use git add to stage new files.)\n'));
662
+ } else {
663
+ console.log(blue('\n Session diff:\n'));
664
+ console.log(dim(diffOut.trim().split('\n').map(l => ' ' + l).join('\n')));
665
+ if (statArgs === '--stat') console.log(dim('\n Tip: /diff --full for full patch\n'));
666
+ else console.log();
667
+ }
668
+ } catch (e) {
669
+ console.log(dim(` No git repository or no changes. (${e.message.slice(0, 60)})\n`));
670
+ }
671
+ break;
672
+ }
673
+ case 'stats': {
674
+ // Session statistics — mirrors cost-tracker.ts formatTotalCost pattern
675
+ const elapsed = Date.now() - sessionStartTime;
676
+ const mins = Math.floor(elapsed / 60000);
677
+ const secs = Math.floor((elapsed % 60000) / 1000);
678
+ const durationStr = mins > 0 ? `${mins}m ${secs}s` : `${secs}s`;
679
+ const totalTok = tokenStats.input + tokenStats.output;
680
+ console.log(blue('\n Session Stats\n'));
681
+ console.log(dim(` Duration ${durationStr}`));
682
+ console.log(dim(` Requests ${tokenStats.requests}`));
683
+ console.log(dim(` Input tokens ${tokenStats.input.toLocaleString()}`));
684
+ console.log(dim(` Output tokens ${tokenStats.output.toLocaleString()}`));
685
+ console.log(dim(` Total tokens ${totalTok.toLocaleString()}`));
686
+ console.log(dim(` Lines added ${linesChanged.added}`));
687
+ console.log(dim(` Lines removed ${linesChanged.removed}`));
688
+ console.log(dim(` Messages ${messages.length}`));
689
+ const models = Object.entries(tokenStats.byModel);
690
+ if (models.length > 1) {
691
+ console.log(blue('\n By model:\n'));
692
+ for (const [model, usage] of models) {
693
+ console.log(dim(` ${model.padEnd(20)} ${usage.input.toLocaleString()} in ${usage.output.toLocaleString()} out`));
694
+ }
695
+ }
696
+ console.log();
697
+ break;
698
+ }
699
+ case 'export': {
700
+ // Export full conversation to a markdown file
701
+ // From CC's export command
702
+ const filename = rest[0] || `tsunami-session-${new Date().toISOString().slice(0,19).replace(/:/g,'-')}.md`;
703
+ const exportPath = filename.startsWith('/') || filename.startsWith('D:') || filename.startsWith('C:') ? filename : join(cwd, filename);
704
+ const lines = [
705
+ `# Tsunami Code Session`,
706
+ ``,
707
+ `**Date:** ${new Date().toISOString()}`,
708
+ `**Directory:** ${cwd}`,
709
+ `**Messages:** ${messages.length}`,
710
+ `**Tokens:** ${(tokenStats.input + tokenStats.output).toLocaleString()}`,
711
+ ``,
712
+ `---`,
713
+ ``
714
+ ];
715
+ for (const msg of messages) {
716
+ if (msg.role === 'system') continue;
717
+ const roleLabel = msg.role === 'user' ? '### User' : '### Assistant';
718
+ const text = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content, null, 2);
719
+ lines.push(roleLabel, '', text, '', '---', '');
720
+ }
721
+ try {
722
+ const { writeFileSync: _wfs } = await import('fs');
723
+ _wfs(exportPath, lines.join('\n'), 'utf8');
724
+ console.log(green(` ✓ Exported to: ${exportPath}\n`));
725
+ } catch (e) {
726
+ console.log(red(` Export failed: ${e.message}\n`));
727
+ }
728
+ break;
729
+ }
547
730
  case 'clear':
548
731
  resetSession();
549
732
  console.log(green(' Session cleared.\n'));
package/lib/loop.js CHANGED
@@ -7,6 +7,20 @@ import {
7
7
  appendDecision
8
8
  } from './memory.js';
9
9
 
10
+ // ── Tool result summarization (generateToolUseSummary pattern) ───────────────
11
+ const SUMMARY_THRESHOLD = 2000; // chars — results larger than this get compressed
12
+ const SUMMARIZABLE_TOOLS = new Set(['Read', 'Bash', 'WebFetch', 'WebSearch', 'Grep']);
13
+
14
+ async function generateToolUseSummary(serverUrl, toolName, result) {
15
+ const systemPrompt =
16
+ 'You are a lossless summarizer for an AI agent\'s working memory. ' +
17
+ 'Extract every key fact, file path, function name, error message, variable name, value, and data point from the tool result. ' +
18
+ 'Preserve anything the agent might need to act on. ' +
19
+ 'Be dense and specific. No preamble. Target: under 350 words.';
20
+ const userMessage = `Tool: ${toolName}\n\nResult:\n${result.slice(0, 12000)}`;
21
+ return await quickCompletion(serverUrl, systemPrompt, userMessage);
22
+ }
23
+
10
24
  // ── Dangerous command detection ──────────────────────────────────────────────
11
25
  const DANGEROUS_PATTERNS = [
12
26
  /rm\s+-[rf]+\s+[^-]/,
@@ -30,7 +44,15 @@ function isDangerous(cmd) {
30
44
  let _serverVerified = false;
31
45
 
32
46
  // Real token tracking from API responses
33
- export const tokenStats = { input: 0, output: 0, requests: 0 };
47
+ // byModel mirrors cost-tracker.ts per-model usage accumulation
48
+ export const tokenStats = { input: 0, output: 0, requests: 0, byModel: {} };
49
+
50
+ // Token warning thresholds from query.ts calculateTokenWarningState
51
+ const TOKEN_WARN_LEVELS = [
52
+ { pct: 0.95, label: '⚠ Context critical — run /compact now', urgent: true },
53
+ { pct: 0.85, label: '⚠ Context high', urgent: true },
54
+ { pct: 0.70, label: '◦ Context at', urgent: false },
55
+ ];
34
56
 
35
57
  // Current model identifier — changeable at runtime via /model command
36
58
  let _currentModel = 'local';
@@ -216,7 +238,16 @@ async function streamCompletion(serverUrl, messages, onToken, memoryContext = ''
216
238
  })
217
239
  });
218
240
 
219
- if (!res.ok) throw new Error(`Model server: ${res.status} ${await res.text()}`);
241
+ if (!res.ok) {
242
+ const errText = await res.text();
243
+ // PROMPT_TOO_LONG detection — mirrors query.ts isPromptTooLongMessage
244
+ if (res.status === 400 || errText.toLowerCase().includes('context') || errText.toLowerCase().includes('too long') || errText.toLowerCase().includes('tokens')) {
245
+ const e = new Error(`Model server: ${res.status} ${errText}`);
246
+ e.isPromptTooLong = true;
247
+ throw e;
248
+ }
249
+ throw new Error(`Model server: ${res.status} ${errText}`);
250
+ }
220
251
 
221
252
  let fullContent = '';
222
253
  let buffer = '';
@@ -239,10 +270,18 @@ async function streamCompletion(serverUrl, messages, onToken, memoryContext = ''
239
270
  }
240
271
  // Capture real token counts + finish reason
241
272
  if (parsed.usage) {
242
- tokenStats.input += parsed.usage.prompt_tokens || 0;
243
- tokenStats.output += parsed.usage.completion_tokens || 0;
273
+ const pt = parsed.usage.prompt_tokens || 0;
274
+ const ct = parsed.usage.completion_tokens || 0;
275
+ tokenStats.input += pt;
276
+ tokenStats.output += ct;
244
277
  tokenStats.requests++;
245
- tokenStats.lastPromptTokens = parsed.usage.prompt_tokens || 0;
278
+ tokenStats.lastPromptTokens = pt;
279
+ // Per-model accumulation
280
+ const m = _currentModel;
281
+ if (!tokenStats.byModel[m]) tokenStats.byModel[m] = { input: 0, output: 0, requests: 0 };
282
+ tokenStats.byModel[m].input += pt;
283
+ tokenStats.byModel[m].output += ct;
284
+ tokenStats.byModel[m].requests++;
246
285
  }
247
286
  if (parsed.choices?.[0]?.finish_reason) {
248
287
  tokenStats.lastFinishReason = parsed.choices[0].finish_reason;
@@ -338,7 +377,34 @@ export async function agentLoop(serverUrl, messages, onToken, onToolCall, sessio
338
377
  tokenStats.lastPromptTokens = 0;
339
378
  }
340
379
 
341
- const content = await streamCompletion(serverUrl, messages, onToken, memoryContext);
380
+ let content;
381
+ try {
382
+ content = await streamCompletion(serverUrl, messages, onToken, memoryContext);
383
+ } catch (e) {
384
+ // PROMPT_TOO_LONG recovery — compact and retry once
385
+ if (e.isPromptTooLong && messages.length > 4) {
386
+ onToken('\n[context too long — auto-compacting and retrying]\n');
387
+ const sys = messages[0];
388
+ const tail = messages.slice(-3);
389
+ messages.length = 0;
390
+ messages.push(sys, ...tail);
391
+ content = await streamCompletion(serverUrl, messages, onToken, memoryContext);
392
+ } else {
393
+ throw e;
394
+ }
395
+ }
396
+
397
+ // Token warning thresholds (from query.ts calculateTokenWarningState)
398
+ if (tokenStats.lastPromptTokens > 0) {
399
+ const pct = tokenStats.lastPromptTokens / CONTEXT_WINDOW;
400
+ for (const level of TOKEN_WARN_LEVELS) {
401
+ if (pct >= level.pct) {
402
+ onToken(`\n[${level.label}${level.urgent ? '' : ` ${Math.round(pct * 100)}%`}]\n`);
403
+ break;
404
+ }
405
+ }
406
+ }
407
+
342
408
  const toolCalls = parseToolCalls(content);
343
409
 
344
410
  messages.push({ role: 'assistant', content });
@@ -412,7 +478,17 @@ export async function agentLoop(serverUrl, messages, onToken, onToolCall, sessio
412
478
  } catch {}
413
479
  }
414
480
 
415
- results.push(`[${tc.name} result]\n${resultStr.slice(0, 8000)}`);
481
+ // Compress large results before storing in context
482
+ let resultForContext = resultStr;
483
+ if (resultStr.length > SUMMARY_THRESHOLD && SUMMARIZABLE_TOOLS.has(tc.name)) {
484
+ try {
485
+ const summary = await generateToolUseSummary(serverUrl, tc.name, resultStr);
486
+ if (summary && summary.length < resultStr.length * 0.75) {
487
+ resultForContext = `[Summarized ${resultStr.length} → ${summary.length} chars]\n${summary}`;
488
+ }
489
+ } catch { /* keep raw result on failure */ }
490
+ }
491
+ results.push(`[${tc.name} result]\n${resultForContext.slice(0, 8000)}`);
416
492
  }
417
493
 
418
494
  messages.push({
package/lib/mcp.js ADDED
@@ -0,0 +1,258 @@
1
+ /**
2
+ * MCP (Model Context Protocol) client for Tsunami Code.
3
+ *
4
+ * Reads ~/.tsunami-code/mcp.json, spawns server processes,
5
+ * speaks JSON-RPC 2.0 over stdio, discovers tools, and wraps
6
+ * them as Tsunami tool objects that plug into ALL_TOOLS.
7
+ *
8
+ * Config format (~/.tsunami-code/mcp.json):
9
+ * {
10
+ * "servers": {
11
+ * "filesystem": {
12
+ * "command": "npx",
13
+ * "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
14
+ * "env": {}
15
+ * }
16
+ * }
17
+ * }
18
+ */
19
+
20
+ import { spawn } from 'child_process';
21
+ import { existsSync, readFileSync } from 'fs';
22
+ import { join } from 'path';
23
+ import os from 'os';
24
+
25
+ const MCP_CONFIG_PATH = join(os.homedir(), '.tsunami-code', 'mcp.json');
26
+ const MCP_PROTOCOL_VERSION = '2024-11-05';
27
+
28
+ // Active connections: name → { process, pending: Map<id, {resolve, reject}>, tools: [], buffer: '' }
29
+ const _servers = new Map();
30
+ let _nextId = 1000;
31
+
32
+ // ── Config ────────────────────────────────────────────────────────────────────
33
+ function loadMcpConfig() {
34
+ if (!existsSync(MCP_CONFIG_PATH)) return { servers: {} };
35
+ try {
36
+ return JSON.parse(readFileSync(MCP_CONFIG_PATH, 'utf8'));
37
+ } catch {
38
+ return { servers: {} };
39
+ }
40
+ }
41
+
42
+ // ── JSON-RPC over stdio ───────────────────────────────────────────────────────
43
+ function sendRequest(server, method, params = {}) {
44
+ return new Promise((resolve, reject) => {
45
+ const id = _nextId++;
46
+ server.pending.set(id, { resolve, reject });
47
+
48
+ const msg = JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n';
49
+ try {
50
+ server.process.stdin.write(msg);
51
+ } catch (e) {
52
+ server.pending.delete(id);
53
+ reject(new Error(`Failed to write to MCP server stdin: ${e.message}`));
54
+ return;
55
+ }
56
+
57
+ // Auto-reject after 10s to prevent hanging
58
+ const timer = setTimeout(() => {
59
+ if (server.pending.has(id)) {
60
+ server.pending.delete(id);
61
+ reject(new Error(`MCP request timed out (${method})`));
62
+ }
63
+ }, 10000);
64
+
65
+ // Clear timer when resolved
66
+ const orig = server.pending.get(id);
67
+ server.pending.set(id, {
68
+ resolve: (v) => { clearTimeout(timer); resolve(v); },
69
+ reject: (e) => { clearTimeout(timer); reject(e); }
70
+ });
71
+ });
72
+ }
73
+
74
+ function sendNotification(server, method, params = {}) {
75
+ try {
76
+ const msg = JSON.stringify({ jsonrpc: '2.0', method, params }) + '\n';
77
+ server.process.stdin.write(msg);
78
+ } catch {}
79
+ }
80
+
81
+ function attachStdioHandler(serverName, server) {
82
+ server.process.stdout.on('data', (chunk) => {
83
+ server.buffer += chunk.toString();
84
+ // Process complete newline-delimited JSON messages
85
+ let nl;
86
+ while ((nl = server.buffer.indexOf('\n')) !== -1) {
87
+ const line = server.buffer.slice(0, nl).trim();
88
+ server.buffer = server.buffer.slice(nl + 1);
89
+ if (!line) continue;
90
+ try {
91
+ const msg = JSON.parse(line);
92
+ // Only handle responses (have id), not notifications
93
+ if (msg.id !== undefined && server.pending.has(msg.id)) {
94
+ const { resolve, reject } = server.pending.get(msg.id);
95
+ server.pending.delete(msg.id);
96
+ if (msg.error) reject(new Error(msg.error.message || JSON.stringify(msg.error)));
97
+ else resolve(msg.result);
98
+ }
99
+ } catch { /* malformed JSON — skip */ }
100
+ }
101
+ });
102
+
103
+ server.process.stderr.on('data', () => {}); // absorb stderr silently
104
+
105
+ server.process.on('error', () => {
106
+ _servers.delete(serverName);
107
+ // Reject all pending requests
108
+ for (const { reject } of server.pending.values()) {
109
+ reject(new Error(`MCP server "${serverName}" crashed`));
110
+ }
111
+ server.pending.clear();
112
+ });
113
+
114
+ server.process.on('exit', () => {
115
+ _servers.delete(serverName);
116
+ for (const { reject } of server.pending.values()) {
117
+ reject(new Error(`MCP server "${serverName}" exited`));
118
+ }
119
+ server.pending.clear();
120
+ });
121
+ }
122
+
123
+ // ── Connect a single server ───────────────────────────────────────────────────
124
+ async function connectServer(name, config) {
125
+ try {
126
+ const { command, args = [], env = {} } = config;
127
+
128
+ if (!command) throw new Error('Missing "command" in MCP server config');
129
+
130
+ const child = spawn(command, args, {
131
+ stdio: ['pipe', 'pipe', 'pipe'],
132
+ env: { ...process.env, ...env },
133
+ shell: process.platform === 'win32'
134
+ });
135
+
136
+ const server = {
137
+ process: child,
138
+ pending: new Map(),
139
+ tools: [],
140
+ buffer: ''
141
+ };
142
+ _servers.set(name, server);
143
+ attachStdioHandler(name, server);
144
+
145
+ // 1. Initialize handshake
146
+ await sendRequest(server, 'initialize', {
147
+ protocolVersion: MCP_PROTOCOL_VERSION,
148
+ capabilities: { tools: {} },
149
+ clientInfo: { name: 'tsunami-code', version: '3.3.0' }
150
+ });
151
+
152
+ // 2. Send initialized notification (spec requires this, no response)
153
+ sendNotification(server, 'notifications/initialized');
154
+
155
+ // 3. Discover tools
156
+ const listResult = await sendRequest(server, 'tools/list', {});
157
+ server.tools = listResult?.tools || [];
158
+
159
+ return { name, toolCount: server.tools.length, tools: server.tools.map(t => t.name) };
160
+ } catch (e) {
161
+ _servers.delete(name);
162
+ return { name, toolCount: 0, error: e.message };
163
+ }
164
+ }
165
+
166
+ // ── Public API ────────────────────────────────────────────────────────────────
167
+
168
+ /**
169
+ * Connect all servers from mcp.json and return a status array.
170
+ * Safe to call even if mcp.json doesn't exist.
171
+ */
172
+ export async function connectMcpServers() {
173
+ const config = loadMcpConfig();
174
+ const entries = Object.entries(config.servers || {});
175
+ if (entries.length === 0) return [];
176
+
177
+ const results = await Promise.allSettled(
178
+ entries.map(([name, cfg]) => connectServer(name, cfg))
179
+ );
180
+
181
+ return results.map(r =>
182
+ r.status === 'fulfilled' ? r.value : { error: r.reason?.message || 'unknown error' }
183
+ );
184
+ }
185
+
186
+ /**
187
+ * Returns Tsunami tool objects for all discovered MCP tools.
188
+ * Call this after connectMcpServers() and push the results into ALL_TOOLS.
189
+ */
190
+ export function getMcpToolObjects() {
191
+ const toolObjects = [];
192
+
193
+ for (const [serverName, server] of _servers) {
194
+ for (const toolDef of server.tools) {
195
+ // Prefix with mcp__ to avoid name collisions with built-ins
196
+ const tsunamiName = `mcp__${serverName}__${toolDef.name}`;
197
+
198
+ toolObjects.push({
199
+ name: tsunamiName,
200
+ description: `[MCP:${serverName}] ${toolDef.description || toolDef.name}\n\nOriginal name: ${toolDef.name}`,
201
+ input_schema: toolDef.inputSchema || { type: 'object', properties: {} },
202
+ async run(args) {
203
+ const srv = _servers.get(serverName);
204
+ if (!srv) return `Error: MCP server "${serverName}" is not connected`;
205
+ try {
206
+ const result = await sendRequest(srv, 'tools/call', {
207
+ name: toolDef.name,
208
+ arguments: args
209
+ });
210
+ // MCP content blocks: [{ type: "text", text: "..." }, { type: "image", ... }]
211
+ const content = result?.content || [];
212
+ if (content.length === 0) return '(empty result)';
213
+ return content
214
+ .map(block => {
215
+ if (block.type === 'text') return block.text;
216
+ if (block.type === 'image') return `[image: ${block.mimeType || 'unknown'}]`;
217
+ return JSON.stringify(block);
218
+ })
219
+ .join('\n');
220
+ } catch (e) {
221
+ return `Error: ${e.message}`;
222
+ }
223
+ }
224
+ });
225
+ }
226
+ }
227
+
228
+ return toolObjects;
229
+ }
230
+
231
+ /**
232
+ * Returns status of all connected servers for /mcp command.
233
+ */
234
+ export function getMcpStatus() {
235
+ if (_servers.size === 0) return [];
236
+ return Array.from(_servers.entries()).map(([name, server]) => ({
237
+ name,
238
+ toolCount: server.tools.length,
239
+ tools: server.tools.map(t => ({ name: t.name, description: (t.description || '').slice(0, 80) }))
240
+ }));
241
+ }
242
+
243
+ /**
244
+ * Returns config file path for user reference.
245
+ */
246
+ export function getMcpConfigPath() {
247
+ return MCP_CONFIG_PATH;
248
+ }
249
+
250
+ /**
251
+ * Kill all child processes on exit.
252
+ */
253
+ export function disconnectAll() {
254
+ for (const [, server] of _servers) {
255
+ try { server.process.kill('SIGTERM'); } catch {}
256
+ }
257
+ _servers.clear();
258
+ }
package/lib/tools.js CHANGED
@@ -8,6 +8,10 @@ import fetch from 'node-fetch';
8
8
 
9
9
  const execAsync = promisify(exec);
10
10
 
11
+ // ── Lines Changed Tracking ────────────────────────────────────────────────────
12
+ // Mirrors cost-tracker.ts addToTotalLinesChanged pattern
13
+ export const linesChanged = { added: 0, removed: 0 };
14
+
11
15
  // ── Undo Stack ───────────────────────────────────────────────────────────────
12
16
  const _undoStack = [];
13
17
  const MAX_UNDO = 20;
@@ -115,8 +119,12 @@ export const WriteTool = {
115
119
  if (_undoStack.length > MAX_UNDO) _undoStack.shift();
116
120
  }
117
121
  } catch {}
122
+ const newLineCount = content.split('\n').length;
123
+ const oldLineCount = existsSync(file_path) ? readFileSync(file_path, 'utf8').split('\n').length : 0;
118
124
  writeFileSync(file_path, content, 'utf8');
119
- return `Written ${content.split('\n').length} lines to ${file_path}`;
125
+ linesChanged.added += Math.max(0, newLineCount - oldLineCount);
126
+ linesChanged.removed += Math.max(0, oldLineCount - newLineCount);
127
+ return `Written ${newLineCount} lines to ${file_path}`;
120
128
  } catch (e) {
121
129
  return `Error writing file: ${e.message}`;
122
130
  }
@@ -159,6 +167,9 @@ export const EditTool = {
159
167
  ? content.split(old_string).join(new_string)
160
168
  : content.replace(old_string, new_string);
161
169
  writeFileSync(file_path, updated, 'utf8');
170
+ // Track lines changed: old_string lines removed, new_string lines added
171
+ linesChanged.added += new_string.split('\n').length;
172
+ linesChanged.removed += old_string.split('\n').length;
162
173
  return `Edited ${file_path}`;
163
174
  } catch (e) {
164
175
  return `Error editing file: ${e.message}`;
@@ -680,3 +691,16 @@ export const ALL_TOOLS = [
680
691
  AgentTool, SnipTool, BriefTool,
681
692
  KairosTool
682
693
  ];
694
+
695
+ /**
696
+ * Register MCP tool objects into the live tool list.
697
+ * Called after MCP servers connect so the agent loop sees them immediately.
698
+ */
699
+ export function registerMcpTools(mcpToolObjects) {
700
+ for (const tool of mcpToolObjects) {
701
+ // Don't double-register if reconnecting
702
+ if (!ALL_TOOLS.find(t => t.name === tool.name)) {
703
+ ALL_TOOLS.push(tool);
704
+ }
705
+ }
706
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tsunami-code",
3
- "version": "3.2.0",
3
+ "version": "3.4.0",
4
4
  "description": "Tsunami Code CLI — AI coding agent by Keystone World Management Navy Seal Unit XI3",
5
5
  "type": "module",
6
6
  "bin": {