tsunami-code 3.1.0 → 3.3.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 +94 -8
- package/lib/coordinator.js +63 -0
- package/lib/kairos.js +75 -0
- package/lib/loop.js +25 -1
- package/lib/mcp.js +258 -0
- package/lib/tools.js +45 -1
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -7,9 +7,12 @@ import os from 'os';
|
|
|
7
7
|
import { agentLoop, quickCompletion, setModel, getModel, tokenStats } from './lib/loop.js';
|
|
8
8
|
import { injectServerContext } from './lib/tools.js';
|
|
9
9
|
import { loadSkills, getSkillCommand, createSkill, listSkills } from './lib/skills.js';
|
|
10
|
+
import { isCoordinatorTask, stripCoordinatorPrefix, buildCoordinatorSystemPrompt } from './lib/coordinator.js';
|
|
11
|
+
import { getDueTasks, markDone, cancelTask, listTasks, formatTaskList } from './lib/kairos.js';
|
|
10
12
|
import { buildSystemPrompt } from './lib/prompt.js';
|
|
11
13
|
import { runPreflight, checkServer } from './lib/preflight.js';
|
|
12
|
-
import { setSession, undo, undoStackSize } from './lib/tools.js';
|
|
14
|
+
import { setSession, undo, undoStackSize, registerMcpTools } from './lib/tools.js';
|
|
15
|
+
import { connectMcpServers, getMcpToolObjects, getMcpStatus, getMcpConfigPath, disconnectAll as disconnectMcp } from './lib/mcp.js';
|
|
13
16
|
import {
|
|
14
17
|
initSession,
|
|
15
18
|
initProjectMemory,
|
|
@@ -22,7 +25,7 @@ import {
|
|
|
22
25
|
getSessionContext
|
|
23
26
|
} from './lib/memory.js';
|
|
24
27
|
|
|
25
|
-
const VERSION = '3.
|
|
28
|
+
const VERSION = '3.3.0';
|
|
26
29
|
const CONFIG_DIR = join(os.homedir(), '.tsunami-code');
|
|
27
30
|
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
|
28
31
|
const DEFAULT_SERVER = 'https://radiometric-reita-amuck.ngrok-free.dev';
|
|
@@ -223,6 +226,47 @@ async function run() {
|
|
|
223
226
|
dim(` · ${sessionId} · Type your task. /help for commands. Ctrl+C to exit.\n`)
|
|
224
227
|
);
|
|
225
228
|
|
|
229
|
+
// MCP: connect servers from ~/.tsunami-code/mcp.json (non-blocking — warnings only)
|
|
230
|
+
{
|
|
231
|
+
const mcpResults = await connectMcpServers();
|
|
232
|
+
if (mcpResults.length > 0) {
|
|
233
|
+
const mcpTools = getMcpToolObjects();
|
|
234
|
+
registerMcpTools(mcpTools);
|
|
235
|
+
const ok = mcpResults.filter(r => !r.error);
|
|
236
|
+
const fail = mcpResults.filter(r => r.error);
|
|
237
|
+
if (ok.length) console.log(green(` ✓ MCP`) + dim(` ${ok.map(r => `${r.name}(${r.toolCount})`).join(', ')}`));
|
|
238
|
+
if (fail.length) {
|
|
239
|
+
for (const f of fail) console.log(yellow(` ⚠ MCP "${f.name}" failed: ${f.error}`));
|
|
240
|
+
}
|
|
241
|
+
if (ok.length || fail.length) console.log();
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// KAIROS: run due background tasks on startup
|
|
246
|
+
const dueTasks = getDueTasks();
|
|
247
|
+
if (dueTasks.length > 0) {
|
|
248
|
+
console.log(yellow(` ⏰ KAIROS: ${dueTasks.length} background task(s) due\n`));
|
|
249
|
+
for (const task of dueTasks) {
|
|
250
|
+
console.log(dim(` Running: ${task.prompt.slice(0, 80)}...\n`));
|
|
251
|
+
const kairosMessages = [
|
|
252
|
+
{ role: 'system', content: buildSystemPrompt() },
|
|
253
|
+
{ role: 'user', content: `[KAIROS background task]\n${task.prompt}` }
|
|
254
|
+
];
|
|
255
|
+
try {
|
|
256
|
+
await agentLoop(serverUrl, kairosMessages,
|
|
257
|
+
(t) => process.stdout.write(t),
|
|
258
|
+
() => {},
|
|
259
|
+
{ sessionDir, cwd, planMode: false },
|
|
260
|
+
null, 10
|
|
261
|
+
);
|
|
262
|
+
markDone(task.id);
|
|
263
|
+
process.stdout.write('\n\n');
|
|
264
|
+
} catch (e) {
|
|
265
|
+
console.log(red(` KAIROS task failed: ${e.message}\n`));
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
226
270
|
// Load last session summary if available
|
|
227
271
|
const lastSessionSummary = getLastSessionSummary(cwd);
|
|
228
272
|
|
|
@@ -291,9 +335,8 @@ async function run() {
|
|
|
291
335
|
|
|
292
336
|
// ── Exit handler ─────────────────────────────────────────────────────────────
|
|
293
337
|
function gracefulExit(code = 0) {
|
|
294
|
-
try {
|
|
295
|
-
|
|
296
|
-
} catch {}
|
|
338
|
+
try { endSession(sessionDir); } catch {}
|
|
339
|
+
try { disconnectMcp(); } catch {}
|
|
297
340
|
console.log(dim('\n Goodbye.\n'));
|
|
298
341
|
process.exit(code);
|
|
299
342
|
}
|
|
@@ -413,7 +456,7 @@ async function run() {
|
|
|
413
456
|
|
|
414
457
|
// Skills: check if this is a skill command before built-ins
|
|
415
458
|
const skillMatch = getSkillCommand(skills, line);
|
|
416
|
-
if (skillMatch && !['help','compact','plan','undo','doctor','cost','clear','status','server','model','memory','history','exit','quit','skill-create','skill-list','skills'].includes(cmd)) {
|
|
459
|
+
if (skillMatch && !['help','compact','plan','undo','doctor','cost','clear','status','server','model','memory','history','exit','quit','skill-create','skill-list','skills','mcp'].includes(cmd)) {
|
|
417
460
|
// Run the skill prompt as a user message
|
|
418
461
|
const userContent = skillMatch.args
|
|
419
462
|
? `[Skill: ${skillMatch.skill.name}]\n${skillMatch.prompt}`
|
|
@@ -454,6 +497,7 @@ async function run() {
|
|
|
454
497
|
['/status', 'Show context size and server'],
|
|
455
498
|
['/server <url>', 'Change model server URL'],
|
|
456
499
|
['/model [name]', 'Show or change active model (default: local)'],
|
|
500
|
+
['/mcp', 'Show MCP server status and tools'],
|
|
457
501
|
['/history', 'Show recent command history'],
|
|
458
502
|
['/exit', 'Exit'],
|
|
459
503
|
];
|
|
@@ -478,6 +522,25 @@ async function run() {
|
|
|
478
522
|
else console.log(dim(' Nothing to undo.\n'));
|
|
479
523
|
break;
|
|
480
524
|
}
|
|
525
|
+
case 'mcp': {
|
|
526
|
+
const mcpServers = getMcpStatus();
|
|
527
|
+
if (mcpServers.length === 0) {
|
|
528
|
+
console.log(dim(`\n No MCP servers connected.`));
|
|
529
|
+
console.log(dim(` Add servers to: ${getMcpConfigPath()}`));
|
|
530
|
+
console.log(dim(' Format: { "servers": { "name": { "command": "npx", "args": [...] } } }\n'));
|
|
531
|
+
} else {
|
|
532
|
+
console.log(blue(`\n MCP Servers (${mcpServers.length})\n`));
|
|
533
|
+
for (const srv of mcpServers) {
|
|
534
|
+
console.log(` ${cyan(srv.name)} ${dim(`— ${srv.toolCount} tool${srv.toolCount !== 1 ? 's' : ''}`)}`);
|
|
535
|
+
for (const tool of srv.tools) {
|
|
536
|
+
const desc = tool.description ? dim(` — ${tool.description}`) : '';
|
|
537
|
+
console.log(` ${dim('•')} ${tool.name}${desc}`);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
console.log();
|
|
541
|
+
}
|
|
542
|
+
break;
|
|
543
|
+
}
|
|
481
544
|
case 'doctor': {
|
|
482
545
|
const { getRgPath } = await import('./lib/preflight.js');
|
|
483
546
|
const { getMemoryStats: _getMemoryStats } = await import('./lib/memory.js');
|
|
@@ -543,6 +606,19 @@ async function run() {
|
|
|
543
606
|
case 'memory':
|
|
544
607
|
await handleMemoryCommand(rest);
|
|
545
608
|
break;
|
|
609
|
+
case 'kairos': {
|
|
610
|
+
const sub = rest[0];
|
|
611
|
+
if (!sub || sub === 'list') {
|
|
612
|
+
console.log(blue('\n KAIROS Scheduled Tasks\n'));
|
|
613
|
+
console.log(dim(formatTaskList(listTasks()) + '\n'));
|
|
614
|
+
} else if (sub === 'cancel' && rest[1]) {
|
|
615
|
+
cancelTask(rest[1]);
|
|
616
|
+
console.log(green(` Cancelled: ${rest[1]}\n`));
|
|
617
|
+
} else {
|
|
618
|
+
console.log(dim(' Usage: /kairos [list|cancel <id>]\n'));
|
|
619
|
+
}
|
|
620
|
+
break;
|
|
621
|
+
}
|
|
546
622
|
case 'skills':
|
|
547
623
|
case 'skill-list':
|
|
548
624
|
console.log(dim('\n' + listSkills(skills) + '\n'));
|
|
@@ -580,13 +656,23 @@ async function run() {
|
|
|
580
656
|
userContent = `[Previous session summary]\n${lastSessionSummary}\n\n---\n\n${line}`;
|
|
581
657
|
}
|
|
582
658
|
|
|
583
|
-
// Frustration injection
|
|
659
|
+
// Frustration injection
|
|
584
660
|
if (detectFrustration(line)) {
|
|
585
661
|
userContent += '\n\n[system: User appears frustrated. Acknowledge the issue directly, do not repeat the same approach. Be concise and action-focused.]';
|
|
586
662
|
}
|
|
587
663
|
|
|
664
|
+
// Coordinator mode — use special system prompt that instructs parallel worker routing
|
|
665
|
+
let activeSystemPrompt = systemPrompt;
|
|
666
|
+
let coordinatorActive = false;
|
|
667
|
+
if (isCoordinatorTask(line)) {
|
|
668
|
+
activeSystemPrompt = buildCoordinatorSystemPrompt(systemPrompt);
|
|
669
|
+
userContent = stripCoordinatorPrefix(userContent);
|
|
670
|
+
coordinatorActive = true;
|
|
671
|
+
process.stdout.write(dim('\n ◈ Coordinator mode activated — routing to parallel workers\n'));
|
|
672
|
+
}
|
|
673
|
+
|
|
588
674
|
const fullMessages = [
|
|
589
|
-
{ role: 'system', content:
|
|
675
|
+
{ role: 'system', content: activeSystemPrompt },
|
|
590
676
|
...messages,
|
|
591
677
|
{ role: 'user', content: userContent }
|
|
592
678
|
];
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coordinator mode — mirrors Claude Code's coordinator/coordinatorMode.js
|
|
3
|
+
*
|
|
4
|
+
* When activated (--coordinator flag or @team prefix), a coordinator agent
|
|
5
|
+
* analyzes the task, decomposes it into independent subtasks, and routes
|
|
6
|
+
* each to a worker Agent. Workers run in parallel. Coordinator aggregates
|
|
7
|
+
* results and produces a final summary.
|
|
8
|
+
*
|
|
9
|
+
* Architecture (from leaked source):
|
|
10
|
+
* Coordinator has a different system prompt:
|
|
11
|
+
* - Knows it is orchestrating workers
|
|
12
|
+
* - Must break task into INDEPENDENT subtasks (no deps between workers)
|
|
13
|
+
* - Must spawn one Agent per subtask
|
|
14
|
+
* - Aggregates worker results into a coherent response
|
|
15
|
+
*
|
|
16
|
+
* Workers:
|
|
17
|
+
* - Each worker only sees its own subtask
|
|
18
|
+
* - No shared state between workers
|
|
19
|
+
* - Results flow back to coordinator via Agent tool return values
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export const COORDINATOR_SYSTEM_INJECTION = `
|
|
23
|
+
<coordinator_mode>
|
|
24
|
+
You are operating as a COORDINATOR agent. Your job is NOT to do the work yourself —
|
|
25
|
+
it is to orchestrate WORKER agents that do the work in parallel.
|
|
26
|
+
|
|
27
|
+
RULES:
|
|
28
|
+
1. Analyze the task and identify subtasks that can run independently
|
|
29
|
+
2. For each independent subtask, call Agent({ task: "..." }) — you can call multiple Agent() in a single response to run them in parallel
|
|
30
|
+
3. Wait for all worker results, then synthesize a final answer
|
|
31
|
+
4. Never do work a worker could do — delegate everything
|
|
32
|
+
5. Keep subtasks self-contained: each worker has no knowledge of other workers
|
|
33
|
+
|
|
34
|
+
SUBTASK DECOMPOSITION GUIDE:
|
|
35
|
+
- "analyze X and Y" → two Agent calls (analyze X, analyze Y)
|
|
36
|
+
- "search for A then use it in B" → NOT parallelizable (sequential dependency)
|
|
37
|
+
- "update files A, B, C independently" → three Agent calls
|
|
38
|
+
- "refactor module X" → one Agent call (single coherent task)
|
|
39
|
+
|
|
40
|
+
When you have all worker results, write a final COORDINATOR SUMMARY that:
|
|
41
|
+
- States what was accomplished
|
|
42
|
+
- Highlights any conflicts or issues workers found
|
|
43
|
+
- Lists files changed
|
|
44
|
+
- Notes anything requiring follow-up
|
|
45
|
+
</coordinator_mode>
|
|
46
|
+
`;
|
|
47
|
+
|
|
48
|
+
export function isCoordinatorTask(input) {
|
|
49
|
+
// Explicit triggers
|
|
50
|
+
if (input.startsWith('@team ') || input.startsWith('@coordinator ')) return true;
|
|
51
|
+
// Heuristic: task mentions multiple independent files/components
|
|
52
|
+
const parallelKeywords = /\b(and also|in parallel|simultaneously|at the same time|both.*and|all of the following)\b/i;
|
|
53
|
+
const multiFile = (input.match(/\b\w+\.\w{2,4}\b/g) || []).length >= 3;
|
|
54
|
+
return parallelKeywords.test(input) && multiFile;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function stripCoordinatorPrefix(input) {
|
|
58
|
+
return input.replace(/^@(team|coordinator)\s+/i, '');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function buildCoordinatorSystemPrompt(baseSystemPrompt) {
|
|
62
|
+
return baseSystemPrompt + COORDINATOR_SYSTEM_INJECTION;
|
|
63
|
+
}
|
package/lib/kairos.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KAIROS — background scheduled tasks.
|
|
3
|
+
* Mirrors Claude Code's unreleased autonomous background agent mode.
|
|
4
|
+
*
|
|
5
|
+
* Tasks stored in ~/.tsunami-code/kairos.json
|
|
6
|
+
* Each task: { id, prompt, schedule, lastRun, status, createdAt }
|
|
7
|
+
* schedule: 'once' | 'hourly' | 'daily' | cron-like string (HH:MM)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
11
|
+
import { join } from 'path';
|
|
12
|
+
import os from 'os';
|
|
13
|
+
import { randomBytes } from 'crypto';
|
|
14
|
+
|
|
15
|
+
const CONFIG_DIR = join(os.homedir(), '.tsunami-code');
|
|
16
|
+
const KAIROS_FILE = join(CONFIG_DIR, 'kairos.json');
|
|
17
|
+
|
|
18
|
+
function load() {
|
|
19
|
+
if (!existsSync(KAIROS_FILE)) return [];
|
|
20
|
+
try { return JSON.parse(readFileSync(KAIROS_FILE, 'utf8')); } catch { return []; }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function save(tasks) {
|
|
24
|
+
if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true });
|
|
25
|
+
writeFileSync(KAIROS_FILE, JSON.stringify(tasks, null, 2), 'utf8');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function scheduleTask(prompt, schedule = 'once') {
|
|
29
|
+
const tasks = load();
|
|
30
|
+
const id = randomBytes(4).toString('hex');
|
|
31
|
+
tasks.push({ id, prompt, schedule, status: 'pending', createdAt: new Date().toISOString(), lastRun: null });
|
|
32
|
+
save(tasks);
|
|
33
|
+
return id;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function listTasks() { return load(); }
|
|
37
|
+
|
|
38
|
+
export function cancelTask(id) {
|
|
39
|
+
const tasks = load().filter(t => t.id !== id);
|
|
40
|
+
save(tasks);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function markDone(id) {
|
|
44
|
+
const tasks = load();
|
|
45
|
+
const t = tasks.find(t => t.id === id);
|
|
46
|
+
if (t) { t.status = t.schedule === 'once' ? 'done' : 'pending'; t.lastRun = new Date().toISOString(); }
|
|
47
|
+
save(tasks);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function getDueTasks() {
|
|
51
|
+
const now = new Date();
|
|
52
|
+
return load().filter(t => {
|
|
53
|
+
if (t.status === 'done') return false;
|
|
54
|
+
if (t.status === 'running') return false;
|
|
55
|
+
if (!t.lastRun) return true; // never run
|
|
56
|
+
const last = new Date(t.lastRun);
|
|
57
|
+
if (t.schedule === 'once') return t.status === 'pending';
|
|
58
|
+
if (t.schedule === 'hourly') return (now - last) >= 3600000;
|
|
59
|
+
if (t.schedule === 'daily') return (now - last) >= 86400000;
|
|
60
|
+
// HH:MM format
|
|
61
|
+
if (/^\d{2}:\d{2}$/.test(t.schedule)) {
|
|
62
|
+
const [h, m] = t.schedule.split(':').map(Number);
|
|
63
|
+
const due = new Date(now); due.setHours(h, m, 0, 0);
|
|
64
|
+
return now >= due && (now - last) >= 86400000;
|
|
65
|
+
}
|
|
66
|
+
return false;
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function formatTaskList(tasks) {
|
|
71
|
+
if (!tasks.length) return 'No KAIROS tasks scheduled.';
|
|
72
|
+
return tasks.map(t =>
|
|
73
|
+
` [${t.id}] ${t.status.padEnd(8)} ${t.schedule.padEnd(8)} ${t.prompt.slice(0, 60)}`
|
|
74
|
+
).join('\n');
|
|
75
|
+
}
|
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+[^-]/,
|
|
@@ -412,7 +426,17 @@ export async function agentLoop(serverUrl, messages, onToken, onToolCall, sessio
|
|
|
412
426
|
} catch {}
|
|
413
427
|
}
|
|
414
428
|
|
|
415
|
-
results
|
|
429
|
+
// Compress large results before storing in context
|
|
430
|
+
let resultForContext = resultStr;
|
|
431
|
+
if (resultStr.length > SUMMARY_THRESHOLD && SUMMARIZABLE_TOOLS.has(tc.name)) {
|
|
432
|
+
try {
|
|
433
|
+
const summary = await generateToolUseSummary(serverUrl, tc.name, resultStr);
|
|
434
|
+
if (summary && summary.length < resultStr.length * 0.75) {
|
|
435
|
+
resultForContext = `[Summarized ${resultStr.length} → ${summary.length} chars]\n${summary}`;
|
|
436
|
+
}
|
|
437
|
+
} catch { /* keep raw result on failure */ }
|
|
438
|
+
}
|
|
439
|
+
results.push(`[${tc.name} result]\n${resultForContext.slice(0, 8000)}`);
|
|
416
440
|
}
|
|
417
441
|
|
|
418
442
|
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
|
@@ -642,10 +642,54 @@ Example:
|
|
|
642
642
|
}
|
|
643
643
|
};
|
|
644
644
|
|
|
645
|
+
// ── KAIROS TOOL ───────────────────────────────────────────────────────────────
|
|
646
|
+
export const KairosTool = {
|
|
647
|
+
name: 'Kairos',
|
|
648
|
+
description: `Schedule a background task to run automatically. Tasks are stored persistently and checked each time Tsunami Code starts.
|
|
649
|
+
|
|
650
|
+
Use for:
|
|
651
|
+
- "Run this check every morning"
|
|
652
|
+
- "Remind me to do X next time I open this project"
|
|
653
|
+
- "Watch for Y and alert me when it changes"
|
|
654
|
+
|
|
655
|
+
Schedules: 'once' | 'hourly' | 'daily' | 'HH:MM' (daily at specific time)`,
|
|
656
|
+
input_schema: {
|
|
657
|
+
type: 'object',
|
|
658
|
+
properties: {
|
|
659
|
+
prompt: { type: 'string', description: 'The task prompt to run when triggered' },
|
|
660
|
+
schedule: { type: 'string', description: "When to run: 'once', 'hourly', 'daily', or 'HH:MM'", default: 'once' }
|
|
661
|
+
},
|
|
662
|
+
required: ['prompt']
|
|
663
|
+
},
|
|
664
|
+
async run({ prompt, schedule = 'once' }) {
|
|
665
|
+
try {
|
|
666
|
+
const { scheduleTask } = await import('./kairos.js');
|
|
667
|
+
const id = scheduleTask(prompt, schedule);
|
|
668
|
+
return `Task scheduled [${id}] — will run ${schedule}. View with /kairos.`;
|
|
669
|
+
} catch (e) {
|
|
670
|
+
return `Error scheduling task: ${e.message}`;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
};
|
|
674
|
+
|
|
645
675
|
export const ALL_TOOLS = [
|
|
646
676
|
BashTool, ReadTool, WriteTool, EditTool, GlobTool, GrepTool,
|
|
647
677
|
NoteTool, CheckpointTool,
|
|
648
678
|
WebFetchTool, WebSearchTool,
|
|
649
679
|
TodoWriteTool, AskUserTool,
|
|
650
|
-
AgentTool, SnipTool, BriefTool
|
|
680
|
+
AgentTool, SnipTool, BriefTool,
|
|
681
|
+
KairosTool
|
|
651
682
|
];
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* Register MCP tool objects into the live tool list.
|
|
686
|
+
* Called after MCP servers connect so the agent loop sees them immediately.
|
|
687
|
+
*/
|
|
688
|
+
export function registerMcpTools(mcpToolObjects) {
|
|
689
|
+
for (const tool of mcpToolObjects) {
|
|
690
|
+
// Don't double-register if reconnecting
|
|
691
|
+
if (!ALL_TOOLS.find(t => t.name === tool.name)) {
|
|
692
|
+
ALL_TOOLS.push(tool);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|