ninja-terminals 2.1.4 → 2.2.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/README.md ADDED
@@ -0,0 +1,156 @@
1
+ # Ninja Terminals
2
+
3
+ **MCP server for multi-terminal Claude Code orchestration** — spawn, manage, and coordinate 1-4+ parallel Claude Code instances with DAG task management and self-improvement.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install -g ninja-terminals
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ### As MCP Server (Recommended)
14
+
15
+ Add to your `.mcp.json`:
16
+
17
+ ```json
18
+ {
19
+ "mcpServers": {
20
+ "ninjaterminal": {
21
+ "command": "npx",
22
+ "args": ["ninja-terminals-mcp"],
23
+ "env": {
24
+ "PORT": "3301",
25
+ "HTTP_PORT": "3300"
26
+ }
27
+ }
28
+ }
29
+ }
30
+ ```
31
+
32
+ Then use the `/ninjaterminal` skill in Claude Code:
33
+
34
+ ```
35
+ /ninjaterminal --terminals 4 --cwd /path/to/project
36
+ ```
37
+
38
+ ### Standalone Server
39
+
40
+ ```bash
41
+ ninja-terminals --port 3300 --terminals 4 --cwd /path/to/project
42
+ ```
43
+
44
+ Open http://localhost:3300 for the web UI.
45
+
46
+ ## MCP Tools
47
+
48
+ Ninja Terminals exposes 12 MCP tools for terminal orchestration:
49
+
50
+ | Tool | Description |
51
+ |------|-------------|
52
+ | `spawn_terminal` | Create a new Claude Code terminal instance |
53
+ | `list_terminals` | Get all terminals with status, elapsed time, context % |
54
+ | `send_input` | Send text/commands to a terminal |
55
+ | `get_terminal_status` | Get detailed status for a specific terminal |
56
+ | `get_terminal_output` | Read recent output lines from a terminal |
57
+ | `get_terminal_log` | Get structured event log (DONE, BLOCKED, ERROR) |
58
+ | `assign_task` | Assign a named task with scope to a terminal |
59
+ | `set_label` | Update a terminal's display label |
60
+ | `kill_terminal` | Stop and remove a terminal |
61
+ | `restart_terminal` | Restart a terminal preserving its label |
62
+ | `get_session_info` | Get session metadata (tier, limits, created) |
63
+ | `end_session` | Finalize session and collect metrics |
64
+
65
+ ## Example Invocations
66
+
67
+ ### Spawn a terminal for building
68
+ ```
69
+ mcp__ninjaterminal__spawn_terminal
70
+ label: "Build"
71
+ scope: ["src/", "lib/"]
72
+ cwd: "/Users/me/project"
73
+ ```
74
+
75
+ ### Send a command
76
+ ```
77
+ mcp__ninjaterminal__send_input
78
+ id: 1
79
+ text: "npm run build && npm test"
80
+ ```
81
+
82
+ ### Check status
83
+ ```
84
+ mcp__ninjaterminal__get_terminal_status
85
+ id: 1
86
+ ```
87
+ Returns: `{id: 1, label: "Build", status: "working", elapsed: 45000, contextPct: 23, taskName: "Build project"}`
88
+
89
+ ### Assign a task
90
+ ```
91
+ mcp__ninjaterminal__assign_task
92
+ id: 1
93
+ name: "Fix auth bug"
94
+ description: "Debug login flow in src/auth/"
95
+ scope: ["src/auth/"]
96
+ ```
97
+
98
+ ### Get output
99
+ ```
100
+ mcp__ninjaterminal__get_terminal_output
101
+ id: 1
102
+ lines: 50
103
+ offset: 0
104
+ ```
105
+
106
+ ### List all terminals
107
+ ```
108
+ mcp__ninjaterminal__list_terminals
109
+ ```
110
+ Returns: `[{id: 1, label: "Build", status: "done"}, {id: 2, label: "Test", status: "working"}]`
111
+
112
+ ## Architecture
113
+
114
+ ```
115
+ Claude Code (your terminal)
116
+ |
117
+ v
118
+ /ninjaterminal skill
119
+ |
120
+ v
121
+ MCP Server (stdio/TCP)
122
+ |
123
+ +-- Spawns PTY instances (node-pty)
124
+ +-- Manages WebSocket connections
125
+ +-- Tracks status via pattern detection
126
+ +-- Serves web UI on localhost:3300
127
+ +-- Self-improves via playbooks/metrics
128
+ ```
129
+
130
+ ## Features
131
+
132
+ - **Parallel Execution**: Run 1-4+ Claude Code instances simultaneously
133
+ - **DAG Task Management**: Define task dependencies, auto-schedule
134
+ - **Status Detection**: Parses `STATUS: DONE/BLOCKED/ERROR` patterns
135
+ - **Self-Improvement**: Tracks tool success rates, evolves playbooks
136
+ - **Web UI**: Real-time terminal grid with xterm.js
137
+ - **Permission Tiers**: Free/Standard/Pro with different limits
138
+ - **Resilience**: Circuit breakers, context compaction handling
139
+
140
+ ## Configuration
141
+
142
+ Environment variables:
143
+ - `PORT` — MCP server port (default: 3301)
144
+ - `HTTP_PORT` — Web UI port (default: 3300)
145
+ - `NINJA_TIER` — Permission tier: free, standard, pro (default: pro)
146
+ - `NINJA_MAX_TERMINALS` — Max concurrent terminals (default: 4)
147
+
148
+ ## Documentation
149
+
150
+ - [MCP Usage Guide](docs/MCP-USAGE.md) — Detailed MCP integration docs
151
+ - [Architecture](docs/ARCHITECTURE-MCP-SCOUT.md) — Technical architecture
152
+ - [CLAUDE.md](CLAUDE.md) — Worker instance guidelines
153
+
154
+ ## License
155
+
156
+ MIT
package/mcp-server.js ADDED
@@ -0,0 +1,712 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Ninja Terminals MCP Server
4
+ *
5
+ * Wraps the terminal orchestration system in an MCP interface.
6
+ * Runs on stdio protocol for Claude Code integration.
7
+ * Also starts HTTP server on port 3300 for browser UI.
8
+ */
9
+
10
+ const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
11
+ const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
12
+ const {
13
+ CallToolRequestSchema,
14
+ ListToolsRequestSchema,
15
+ } = require('@modelcontextprotocol/sdk/types.js');
16
+
17
+ const http = require('http');
18
+ const express = require('express');
19
+ const { WebSocketServer } = require('ws');
20
+ const pty = require('node-pty');
21
+ const path = require('path');
22
+ const os = require('os');
23
+
24
+ // ── Lib imports ─────────────────────────────────────────────
25
+ const { LineBuffer, RawBuffer } = require('./lib/ring-buffer');
26
+ const { stripAnsi, detectStatus, extractContextPct, extractStructuredEvents } = require('./lib/status-detect');
27
+ const { SSEManager } = require('./lib/sse');
28
+ const { writeWorkerSettings } = require('./lib/settings-gen');
29
+ const { getPreDispatchContext, formatContextForInjection } = require('./lib/pre-dispatch');
30
+ const { runPostSession } = require('./lib/post-session');
31
+
32
+ // ── Config ──────────────────────────────────────────────────
33
+ const HTTP_PORT = parseInt(process.env.HTTP_PORT || '3300', 10);
34
+ const CLAUDE_CMD = process.env.CLAUDE_CMD || 'claude --dangerously-skip-permissions';
35
+ const SHELL = process.env.SHELL || '/bin/zsh';
36
+ const PROJECT_DIR = __dirname;
37
+ const INJECT_GUIDANCE = process.env.INJECT_GUIDANCE !== 'false';
38
+
39
+ const sleep = ms => new Promise(r => setTimeout(r, ms));
40
+
41
+ // ── Global State ────────────────────────────────────────────
42
+ let nextId = 1;
43
+ const terminals = new Map();
44
+ let activeSession = {
45
+ tier: 'pro',
46
+ terminalsMax: 10,
47
+ features: ['all'],
48
+ terminalIds: [],
49
+ createdAt: Date.now(),
50
+ };
51
+
52
+ // ── Express + WebSocket (for browser UI) ────────────────────
53
+ const app = express();
54
+ const httpServer = http.createServer(app);
55
+ const wss = new WebSocketServer({ noServer: true });
56
+ const sse = new SSEManager();
57
+
58
+ app.use(express.json());
59
+ app.use(express.static(path.join(__dirname, 'public')));
60
+
61
+ // ── Helper Functions ────────────────────────────────────────
62
+
63
+ function getElapsed(terminal) {
64
+ if (!terminal.taskStartedAt) return null;
65
+ const ms = Date.now() - terminal.taskStartedAt;
66
+ const s = Math.floor(ms / 1000);
67
+ if (s < 60) return `${s}s`;
68
+ const m = Math.floor(s / 60);
69
+ const rem = s % 60;
70
+ return `${m}m ${rem}s`;
71
+ }
72
+
73
+ function getTerminalInfo(t) {
74
+ const recentLines = t.lineBuffer.last(50);
75
+ return {
76
+ id: t.id,
77
+ label: t.label,
78
+ status: t.status,
79
+ elapsed: getElapsed(t),
80
+ contextPct: extractContextPct(recentLines),
81
+ taskName: t.taskName,
82
+ progress: t.progress,
83
+ scope: t.scope,
84
+ cwd: t.cwd,
85
+ };
86
+ }
87
+
88
+ // ── Terminal Spawning ───────────────────────────────────────
89
+
90
+ function spawnTerminal(label, scope = [], cwd = null, tier = 'pro') {
91
+ const id = nextId++;
92
+ const cols = 120;
93
+ const rows = 30;
94
+
95
+ const workDir = cwd || PROJECT_DIR;
96
+ const settingsDir = cwd || PROJECT_DIR;
97
+
98
+ // Write worker settings
99
+ try {
100
+ writeWorkerSettings(id, settingsDir, scope, { port: HTTP_PORT, tier });
101
+ } catch (e) {
102
+ console.error(`Failed to write worker settings for terminal ${id}:`, e.message);
103
+ }
104
+
105
+ // Clean env
106
+ const cleanEnv = {};
107
+ for (const [k, v] of Object.entries(process.env)) {
108
+ if (v !== undefined && k !== 'CLAUDECODE' && !k.startsWith('CLAUDE_')) {
109
+ cleanEnv[k] = v;
110
+ }
111
+ }
112
+
113
+ const ptyProcess = pty.spawn(SHELL, [], {
114
+ name: 'xterm-256color',
115
+ cols,
116
+ rows,
117
+ cwd: workDir,
118
+ env: {
119
+ ...cleanEnv,
120
+ TERM: 'xterm-256color',
121
+ HOME: os.homedir(),
122
+ PATH: `${os.homedir()}/.local/bin:/opt/homebrew/bin:${process.env.PATH || ''}`,
123
+ SHELL_SESSIONS_DISABLE: '1',
124
+ NINJA_TERMINAL_ID: String(id),
125
+ },
126
+ });
127
+
128
+ // Launch claude after shell starts
129
+ setTimeout(() => {
130
+ ptyProcess.write(`cd "${workDir}" && ${CLAUDE_CMD}\r`);
131
+ }, 500);
132
+
133
+ const terminal = {
134
+ id,
135
+ label: label || `T${id}`,
136
+ pty: ptyProcess,
137
+ clients: new Set(),
138
+ status: 'starting',
139
+ startedAt: Date.now(),
140
+ taskStartedAt: Date.now(),
141
+ lastActivity: Date.now(),
142
+ rawBuffer: new RawBuffer(65536),
143
+ lineBuffer: new LineBuffer(1000),
144
+ structuredLog: [],
145
+ cols,
146
+ rows,
147
+ taskName: null,
148
+ progress: null,
149
+ scope: Array.isArray(scope) ? scope : (scope ? [scope] : []),
150
+ cwd: workDir,
151
+ };
152
+
153
+ // PTY data handler
154
+ ptyProcess.onData((data) => {
155
+ terminal.lastActivity = Date.now();
156
+ terminal.rawBuffer.push(data);
157
+
158
+ const stripped = stripAnsi(data);
159
+ const lines = stripped.split('\n').filter(l => l.trim());
160
+ for (const line of lines) {
161
+ terminal.lineBuffer.push(line);
162
+ }
163
+
164
+ // Extract structured events
165
+ const events = extractStructuredEvents(lines, terminal.label);
166
+ for (const evt of events) {
167
+ terminal.structuredLog.push(evt);
168
+ if (terminal.structuredLog.length > 500) terminal.structuredLog.shift();
169
+ sse.broadcast(evt.type, evt);
170
+ }
171
+
172
+ // Broadcast to WebSocket clients
173
+ for (const ws of terminal.clients) {
174
+ if (ws.readyState === 1) ws.send(data);
175
+ }
176
+ });
177
+
178
+ ptyProcess.onExit(({ exitCode }) => {
179
+ terminal.status = 'exited';
180
+ console.error(`Terminal ${id} exited with code ${exitCode}`);
181
+ sse.broadcast('status_change', {
182
+ terminal: terminal.label,
183
+ id: terminal.id,
184
+ from: terminal.status,
185
+ to: 'exited',
186
+ elapsed: getElapsed(terminal),
187
+ });
188
+ });
189
+
190
+ terminals.set(id, terminal);
191
+ activeSession.terminalIds.push(id);
192
+ console.error(`Spawned terminal ${id} (${terminal.label})${scope.length ? ` scope: ${scope}` : ''}`);
193
+ return terminal;
194
+ }
195
+
196
+ // ── Status Detection Loop ───────────────────────────────────
197
+
198
+ setInterval(() => {
199
+ for (const [, terminal] of terminals) {
200
+ if (terminal.status === 'exited') continue;
201
+ const prev = terminal.status;
202
+ const recentLines = terminal.lineBuffer.last(50);
203
+ const newStatus = detectStatus(recentLines);
204
+
205
+ if (newStatus !== prev) {
206
+ terminal.status = newStatus;
207
+ sse.broadcast('status_change', {
208
+ terminal: terminal.label,
209
+ id: terminal.id,
210
+ from: prev,
211
+ to: newStatus,
212
+ elapsed: getElapsed(terminal),
213
+ });
214
+
215
+ if (newStatus === 'working' && prev !== 'working') {
216
+ terminal.taskStartedAt = Date.now();
217
+ }
218
+ if (newStatus === 'done' || newStatus === 'idle') {
219
+ terminal.taskStartedAt = null;
220
+ }
221
+ }
222
+
223
+ // Context window check
224
+ const ctx = extractContextPct(recentLines);
225
+ if (ctx && ctx > 80) {
226
+ sse.broadcast('context_low', {
227
+ terminal: terminal.label,
228
+ id: terminal.id,
229
+ usage: ctx,
230
+ threshold: 80,
231
+ });
232
+ }
233
+ }
234
+ }, 2000);
235
+
236
+ // ── WebSocket Upgrade (for browser UI) ──────────────────────
237
+
238
+ httpServer.on('upgrade', (req, socket, head) => {
239
+ const urlParts = new URL(req.url, `http://${req.headers.host}`);
240
+ const match = urlParts.pathname.match(/^\/ws\/(\d+)$/);
241
+ if (!match) {
242
+ socket.destroy();
243
+ return;
244
+ }
245
+
246
+ const id = parseInt(match[1], 10);
247
+ const terminal = terminals.get(id);
248
+ if (!terminal || terminal.status === 'exited') {
249
+ socket.destroy();
250
+ return;
251
+ }
252
+
253
+ wss.handleUpgrade(req, socket, head, (ws) => {
254
+ terminal.clients.add(ws);
255
+
256
+ const buffered = terminal.rawBuffer.getAll();
257
+ if (buffered) ws.send(buffered);
258
+
259
+ ws.on('message', (msg) => {
260
+ const data = msg.toString();
261
+ try {
262
+ const parsed = JSON.parse(data);
263
+ if (parsed.type === 'resize' && parsed.cols && parsed.rows) {
264
+ terminal.pty.resize(parsed.cols, parsed.rows);
265
+ return;
266
+ }
267
+ } catch { /* not JSON */ }
268
+ terminal.pty.write(data);
269
+ });
270
+
271
+ ws.on('close', () => terminal.clients.delete(ws));
272
+ });
273
+ });
274
+
275
+ // ── HTTP Routes (for browser UI) ────────────────────────────
276
+
277
+ app.get('/health', (_req, res) => {
278
+ res.json({
279
+ status: 'ok',
280
+ version: '2.1.5-mcp',
281
+ terminals: terminals.size,
282
+ mode: 'mcp',
283
+ });
284
+ });
285
+
286
+ app.get('/api/events', (req, res) => {
287
+ sse.addClient(res);
288
+ req.on('close', () => sse.removeClient(res));
289
+ });
290
+
291
+ app.get('/api/terminals', (_req, res) => {
292
+ const list = [];
293
+ for (const [, t] of terminals) {
294
+ list.push(getTerminalInfo(t));
295
+ }
296
+ res.json(list);
297
+ });
298
+
299
+ // ── MCP Server Setup ────────────────────────────────────────
300
+
301
+ const mcpServer = new Server(
302
+ { name: 'ninja-terminals', version: '2.1.5' },
303
+ { capabilities: { tools: {} } }
304
+ );
305
+
306
+ // ── MCP Tool Definitions ────────────────────────────────────
307
+
308
+ const TOOLS = [
309
+ {
310
+ name: 'spawn_terminal',
311
+ description: 'Spawn a new Claude Code terminal instance. Returns terminal ID and URLs for web/WebSocket access.',
312
+ inputSchema: {
313
+ type: 'object',
314
+ properties: {
315
+ label: { type: 'string', description: 'Terminal label (e.g., "Build", "Test", "Research")' },
316
+ scope: { type: 'array', items: { type: 'string' }, description: 'File scope paths for permission restrictions' },
317
+ cwd: { type: 'string', description: 'Working directory for the terminal' },
318
+ tier: { type: 'string', enum: ['free', 'standard', 'pro'], description: 'Permission tier (default: pro)' },
319
+ },
320
+ },
321
+ },
322
+ {
323
+ name: 'send_input',
324
+ description: 'Send text input to a terminal. Automatically injects learned guidance from prior sessions.',
325
+ inputSchema: {
326
+ type: 'object',
327
+ properties: {
328
+ id: { type: 'number', description: 'Terminal ID' },
329
+ text: { type: 'string', description: 'Text to send to the terminal' },
330
+ },
331
+ required: ['id', 'text'],
332
+ },
333
+ },
334
+ {
335
+ name: 'list_terminals',
336
+ description: 'List all active terminals with their status, elapsed time, and context window usage.',
337
+ inputSchema: { type: 'object', properties: {} },
338
+ },
339
+ {
340
+ name: 'get_terminal_status',
341
+ description: 'Get detailed status of a specific terminal including context%, task name, and progress.',
342
+ inputSchema: {
343
+ type: 'object',
344
+ properties: {
345
+ id: { type: 'number', description: 'Terminal ID' },
346
+ },
347
+ required: ['id'],
348
+ },
349
+ },
350
+ {
351
+ name: 'get_terminal_output',
352
+ description: 'Get paginated output lines from a terminal.',
353
+ inputSchema: {
354
+ type: 'object',
355
+ properties: {
356
+ id: { type: 'number', description: 'Terminal ID' },
357
+ lines: { type: 'number', description: 'Number of lines to retrieve (default: 50)' },
358
+ offset: { type: 'number', description: 'Offset from start (default: 0)' },
359
+ },
360
+ required: ['id'],
361
+ },
362
+ },
363
+ {
364
+ name: 'get_terminal_log',
365
+ description: 'Get structured event log from a terminal (DONE, BLOCKED, ERROR, PROGRESS events).',
366
+ inputSchema: {
367
+ type: 'object',
368
+ properties: {
369
+ id: { type: 'number', description: 'Terminal ID' },
370
+ },
371
+ required: ['id'],
372
+ },
373
+ },
374
+ {
375
+ name: 'kill_terminal',
376
+ description: 'Gracefully kill a terminal (SIGINT -> SIGTERM -> SIGKILL).',
377
+ inputSchema: {
378
+ type: 'object',
379
+ properties: {
380
+ id: { type: 'number', description: 'Terminal ID' },
381
+ },
382
+ required: ['id'],
383
+ },
384
+ },
385
+ {
386
+ name: 'restart_terminal',
387
+ description: 'Restart a terminal with the same configuration (label, scope, cwd).',
388
+ inputSchema: {
389
+ type: 'object',
390
+ properties: {
391
+ id: { type: 'number', description: 'Terminal ID' },
392
+ },
393
+ required: ['id'],
394
+ },
395
+ },
396
+ {
397
+ name: 'set_label',
398
+ description: 'Set or change a terminal label.',
399
+ inputSchema: {
400
+ type: 'object',
401
+ properties: {
402
+ id: { type: 'number', description: 'Terminal ID' },
403
+ label: { type: 'string', description: 'New label' },
404
+ },
405
+ required: ['id', 'label'],
406
+ },
407
+ },
408
+ {
409
+ name: 'assign_task',
410
+ description: 'Assign a named task to a terminal. Updates task tracking and optionally sends task description as input.',
411
+ inputSchema: {
412
+ type: 'object',
413
+ properties: {
414
+ id: { type: 'number', description: 'Terminal ID' },
415
+ name: { type: 'string', description: 'Task name' },
416
+ description: { type: 'string', description: 'Task description (sent to terminal as input)' },
417
+ scope: { type: 'array', items: { type: 'string' }, description: 'Updated file scope for this task' },
418
+ },
419
+ required: ['id', 'name'],
420
+ },
421
+ },
422
+ {
423
+ name: 'get_session_info',
424
+ description: 'Get current session information including tier, terminal count, and active terminals.',
425
+ inputSchema: { type: 'object', properties: {} },
426
+ },
427
+ {
428
+ name: 'finalize_session',
429
+ description: 'Trigger post-session automation: tool rating, hypothesis validation, playbook evolution.',
430
+ inputSchema: { type: 'object', properties: {} },
431
+ },
432
+ ];
433
+
434
+ // ── MCP Tool Handlers ───────────────────────────────────────
435
+
436
+ mcpServer.setRequestHandler(ListToolsRequestSchema, async () => {
437
+ return { tools: TOOLS };
438
+ });
439
+
440
+ mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => {
441
+ const { name, arguments: args } = request.params;
442
+
443
+ try {
444
+ switch (name) {
445
+ case 'spawn_terminal': {
446
+ const terminal = spawnTerminal(
447
+ args.label || null,
448
+ args.scope || [],
449
+ args.cwd || null,
450
+ args.tier || 'pro'
451
+ );
452
+ return {
453
+ content: [{
454
+ type: 'text',
455
+ text: JSON.stringify({
456
+ id: terminal.id,
457
+ label: terminal.label,
458
+ status: terminal.status,
459
+ cwd: terminal.cwd,
460
+ webUrl: `http://localhost:${HTTP_PORT}`,
461
+ wsUrl: `ws://localhost:${HTTP_PORT}/ws/${terminal.id}`,
462
+ }, null, 2),
463
+ }],
464
+ };
465
+ }
466
+
467
+ case 'send_input': {
468
+ const terminal = terminals.get(args.id);
469
+ if (!terminal) {
470
+ return { content: [{ type: 'text', text: JSON.stringify({ error: 'Terminal not found' }) }], isError: true };
471
+ }
472
+
473
+ let finalText = args.text;
474
+ let guidanceInjected = false;
475
+
476
+ if (INJECT_GUIDANCE) {
477
+ try {
478
+ const ctx = await getPreDispatchContext();
479
+ const hasGuidance = ctx.toolGuidance.length > 0 || ctx.playbookInsights.length > 0;
480
+ if (hasGuidance) {
481
+ const guidanceBlock = formatContextForInjection(ctx);
482
+ finalText = `${guidanceBlock}\n\n${args.text}`;
483
+ guidanceInjected = true;
484
+ }
485
+ } catch { /* continue without guidance */ }
486
+ }
487
+
488
+ terminal.pty.write(finalText);
489
+ return {
490
+ content: [{ type: 'text', text: JSON.stringify({ ok: true, guidanceInjected }) }],
491
+ };
492
+ }
493
+
494
+ case 'list_terminals': {
495
+ const list = [];
496
+ for (const [, t] of terminals) {
497
+ list.push(getTerminalInfo(t));
498
+ }
499
+ return {
500
+ content: [{ type: 'text', text: JSON.stringify(list, null, 2) }],
501
+ };
502
+ }
503
+
504
+ case 'get_terminal_status': {
505
+ const terminal = terminals.get(args.id);
506
+ if (!terminal) {
507
+ return { content: [{ type: 'text', text: JSON.stringify({ error: 'Terminal not found' }) }], isError: true };
508
+ }
509
+ return {
510
+ content: [{ type: 'text', text: JSON.stringify(getTerminalInfo(terminal), null, 2) }],
511
+ };
512
+ }
513
+
514
+ case 'get_terminal_output': {
515
+ const terminal = terminals.get(args.id);
516
+ if (!terminal) {
517
+ return { content: [{ type: 'text', text: JSON.stringify({ error: 'Terminal not found' }) }], isError: true };
518
+ }
519
+ const lines = args.lines || 50;
520
+ const offset = args.offset || 0;
521
+ const output = terminal.lineBuffer.slice(offset, lines);
522
+ return {
523
+ content: [{ type: 'text', text: JSON.stringify({ lines: output, offset, count: output.length }, null, 2) }],
524
+ };
525
+ }
526
+
527
+ case 'get_terminal_log': {
528
+ const terminal = terminals.get(args.id);
529
+ if (!terminal) {
530
+ return { content: [{ type: 'text', text: JSON.stringify({ error: 'Terminal not found' }) }], isError: true };
531
+ }
532
+ return {
533
+ content: [{ type: 'text', text: JSON.stringify(terminal.structuredLog, null, 2) }],
534
+ };
535
+ }
536
+
537
+ case 'kill_terminal': {
538
+ const terminal = terminals.get(args.id);
539
+ if (!terminal) {
540
+ return { content: [{ type: 'text', text: JSON.stringify({ error: 'Terminal not found' }) }], isError: true };
541
+ }
542
+
543
+ if (terminal.status === 'exited') {
544
+ return { content: [{ type: 'text', text: JSON.stringify({ ok: true, message: 'Already exited' }) }] };
545
+ }
546
+
547
+ // Graceful kill: SIGINT -> SIGTERM -> SIGKILL
548
+ terminal.pty.kill('SIGINT');
549
+ await sleep(5000);
550
+ if (terminal.status !== 'exited') {
551
+ terminal.pty.kill('SIGTERM');
552
+ await sleep(3000);
553
+ if (terminal.status !== 'exited') {
554
+ terminal.pty.kill('SIGKILL');
555
+ }
556
+ }
557
+
558
+ for (const ws of terminal.clients) ws.close();
559
+ terminals.delete(args.id);
560
+ activeSession.terminalIds = activeSession.terminalIds.filter(id => id !== args.id);
561
+
562
+ return { content: [{ type: 'text', text: JSON.stringify({ ok: true }) }] };
563
+ }
564
+
565
+ case 'restart_terminal': {
566
+ const terminal = terminals.get(args.id);
567
+ if (!terminal) {
568
+ return { content: [{ type: 'text', text: JSON.stringify({ error: 'Terminal not found' }) }], isError: true };
569
+ }
570
+
571
+ const label = terminal.label;
572
+ const scope = terminal.scope;
573
+ const cwd = terminal.cwd;
574
+
575
+ // Kill old terminal
576
+ terminal.pty.kill();
577
+ for (const ws of terminal.clients) ws.close();
578
+ terminals.delete(args.id);
579
+ activeSession.terminalIds = activeSession.terminalIds.filter(id => id !== args.id);
580
+
581
+ // Spawn new
582
+ const newTerminal = spawnTerminal(label, scope, cwd, 'pro');
583
+
584
+ return {
585
+ content: [{
586
+ type: 'text',
587
+ text: JSON.stringify({
588
+ id: newTerminal.id,
589
+ label: newTerminal.label,
590
+ status: newTerminal.status,
591
+ previousId: args.id,
592
+ }, null, 2),
593
+ }],
594
+ };
595
+ }
596
+
597
+ case 'set_label': {
598
+ const terminal = terminals.get(args.id);
599
+ if (!terminal) {
600
+ return { content: [{ type: 'text', text: JSON.stringify({ error: 'Terminal not found' }) }], isError: true };
601
+ }
602
+ terminal.label = args.label;
603
+ return { content: [{ type: 'text', text: JSON.stringify({ ok: true, label: terminal.label }) }] };
604
+ }
605
+
606
+ case 'assign_task': {
607
+ const terminal = terminals.get(args.id);
608
+ if (!terminal) {
609
+ return { content: [{ type: 'text', text: JSON.stringify({ error: 'Terminal not found' }) }], isError: true };
610
+ }
611
+
612
+ terminal.taskName = args.name;
613
+ terminal.progress = null;
614
+ terminal.taskStartedAt = Date.now();
615
+
616
+ if (args.scope) {
617
+ terminal.scope = Array.isArray(args.scope) ? args.scope : [args.scope];
618
+ }
619
+
620
+ sse.broadcast('task_assigned', {
621
+ terminal: terminal.label,
622
+ id: terminal.id,
623
+ taskName: args.name,
624
+ description: args.description || null,
625
+ scope: terminal.scope,
626
+ ts: new Date().toISOString(),
627
+ });
628
+
629
+ // Send task description as input
630
+ if (args.description) {
631
+ terminal.pty.write(`${args.description}\r`);
632
+ }
633
+
634
+ return {
635
+ content: [{ type: 'text', text: JSON.stringify({ ok: true, taskName: args.name, scope: terminal.scope }) }],
636
+ };
637
+ }
638
+
639
+ case 'get_session_info': {
640
+ const sessionTerminals = activeSession.terminalIds
641
+ .map(id => terminals.get(id))
642
+ .filter(Boolean)
643
+ .map(t => getTerminalInfo(t));
644
+
645
+ return {
646
+ content: [{
647
+ type: 'text',
648
+ text: JSON.stringify({
649
+ tier: activeSession.tier,
650
+ terminalsMax: activeSession.terminalsMax,
651
+ features: activeSession.features,
652
+ terminals: sessionTerminals,
653
+ createdAt: activeSession.createdAt,
654
+ }, null, 2),
655
+ }],
656
+ };
657
+ }
658
+
659
+ case 'finalize_session': {
660
+ try {
661
+ const result = await runPostSession();
662
+ sse.broadcast('session_end', {
663
+ filesProcessed: result.filesProcessed,
664
+ toolsRated: Object.keys(result.toolRatings).length,
665
+ hypothesesPromoted: result.hypothesisValidation.promoted,
666
+ hypothesesRejected: result.hypothesisValidation.rejected,
667
+ duration_ms: result.duration_ms,
668
+ ts: result.ts,
669
+ });
670
+ return {
671
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
672
+ };
673
+ } catch (err) {
674
+ return {
675
+ content: [{ type: 'text', text: JSON.stringify({ error: 'Post-session failed', detail: err.message }) }],
676
+ isError: true,
677
+ };
678
+ }
679
+ }
680
+
681
+ default:
682
+ return {
683
+ content: [{ type: 'text', text: JSON.stringify({ error: `Unknown tool: ${name}` }) }],
684
+ isError: true,
685
+ };
686
+ }
687
+ } catch (err) {
688
+ return {
689
+ content: [{ type: 'text', text: JSON.stringify({ error: err.message }) }],
690
+ isError: true,
691
+ };
692
+ }
693
+ });
694
+
695
+ // ── Start Servers ───────────────────────────────────────────
696
+
697
+ async function main() {
698
+ // Start HTTP server for browser UI
699
+ httpServer.listen(HTTP_PORT, () => {
700
+ console.error(`Ninja Terminals HTTP server running on http://localhost:${HTTP_PORT}`);
701
+ });
702
+
703
+ // Start MCP server on stdio
704
+ const transport = new StdioServerTransport();
705
+ await mcpServer.connect(transport);
706
+ console.error('Ninja Terminals MCP server running on stdio');
707
+ }
708
+
709
+ main().catch((err) => {
710
+ console.error('Fatal error:', err);
711
+ process.exit(1);
712
+ });
package/package.json CHANGED
@@ -1,13 +1,15 @@
1
1
  {
2
2
  "name": "ninja-terminals",
3
- "version": "2.1.4",
4
- "description": "Multi-terminal Claude Code orchestrator with DAG task management, permission hooks, and resilience",
3
+ "version": "2.2.0",
4
+ "description": "MCP server for multi-terminal Claude Code orchestration with DAG task management, parallel execution, and self-improvement",
5
5
  "main": "server.js",
6
6
  "bin": {
7
- "ninja-terminals": "cli.js"
7
+ "ninja-terminals": "cli.js",
8
+ "ninja-terminals-mcp": "mcp-server.js"
8
9
  },
9
10
  "scripts": {
10
- "start": "node server.js"
11
+ "start": "node server.js",
12
+ "mcp": "node mcp-server.js"
11
13
  },
12
14
  "files": [
13
15
  "lib/",
@@ -22,6 +24,7 @@
22
24
  "prompts/",
23
25
  "cli.js",
24
26
  "server.js",
27
+ "mcp-server.js",
25
28
  "CLAUDE.md",
26
29
  "ORCHESTRATOR-PROMPT.md"
27
30
  ],
@@ -32,7 +35,10 @@
32
35
  "terminal",
33
36
  "orchestrator",
34
37
  "agents",
35
- "multi-agent"
38
+ "multi-agent",
39
+ "mcp",
40
+ "model-context-protocol",
41
+ "mcp-server"
36
42
  ],
37
43
  "author": "",
38
44
  "license": "MIT",
@@ -47,6 +53,7 @@
47
53
  "type": "commonjs",
48
54
  "dependencies": {
49
55
  "@anthropic-ai/sdk": "^0.80.0",
56
+ "@modelcontextprotocol/sdk": "^1.29.0",
50
57
  "cheerio": "^1.2.0",
51
58
  "express": "^5.2.1",
52
59
  "multer": "^2.1.1",
package/public/app.js CHANGED
@@ -62,6 +62,26 @@ const auth = {
62
62
  return data;
63
63
  },
64
64
 
65
+ async register(username, email, password) {
66
+ const res = await fetch(`${AUTH_API}/auth/register`, {
67
+ method: 'POST',
68
+ headers: { 'Content-Type': 'application/json' },
69
+ body: JSON.stringify({ username, email, password }),
70
+ });
71
+
72
+ if (!res.ok) {
73
+ const err = await res.json().catch(() => ({}));
74
+ throw new Error(err.message || 'Registration failed');
75
+ }
76
+
77
+ const data = await res.json();
78
+ this.token = data.token || data.accessToken;
79
+ localStorage.setItem(TOKEN_KEY, this.token);
80
+
81
+ await this.validateTier();
82
+ return data;
83
+ },
84
+
65
85
  async activateLicense(key) {
66
86
  const res = await fetch(`${AUTH_API}/ninja/activate-license`, {
67
87
  method: 'POST',
@@ -143,10 +163,45 @@ function hideAuthOverlay() {
143
163
 
144
164
  function setupAuthForms() {
145
165
  const loginForm = document.getElementById('login-form');
166
+ const registerForm = document.getElementById('register-form');
146
167
  const licenseForm = document.getElementById('license-form');
147
168
  const loginError = document.getElementById('login-error');
169
+ const registerError = document.getElementById('register-error');
148
170
  const logoutBtn = document.getElementById('logout-btn');
171
+ const showRegisterLink = document.getElementById('show-register');
172
+ const authToggleText = document.getElementById('auth-toggle-text');
173
+
174
+ // Toggle between login and register
175
+ let showingRegister = false;
176
+
177
+ function toggleAuthMode() {
178
+ showingRegister = !showingRegister;
179
+ if (showingRegister) {
180
+ loginForm.classList.add('hidden');
181
+ registerForm.classList.remove('hidden');
182
+ authToggleText.innerHTML = 'Already have an account? <a href="#" id="show-register">Sign in</a>';
183
+ document.getElementById('register-username').focus();
184
+ } else {
185
+ registerForm.classList.add('hidden');
186
+ loginForm.classList.remove('hidden');
187
+ authToggleText.innerHTML = 'Don\'t have an account? <a href="#" id="show-register">Sign up</a>';
188
+ document.getElementById('login-email').focus();
189
+ }
190
+ // Re-attach click handler to new link
191
+ document.getElementById('show-register').addEventListener('click', (e) => {
192
+ e.preventDefault();
193
+ toggleAuthMode();
194
+ });
195
+ loginError.textContent = '';
196
+ registerError.textContent = '';
197
+ }
198
+
199
+ showRegisterLink.addEventListener('click', (e) => {
200
+ e.preventDefault();
201
+ toggleAuthMode();
202
+ });
149
203
 
204
+ // Login form
150
205
  loginForm.addEventListener('submit', async (e) => {
151
206
  e.preventDefault();
152
207
  loginError.textContent = '';
@@ -163,6 +218,30 @@ function setupAuthForms() {
163
218
  }
164
219
  });
165
220
 
221
+ // Register form
222
+ registerForm.addEventListener('submit', async (e) => {
223
+ e.preventDefault();
224
+ registerError.textContent = '';
225
+
226
+ const username = document.getElementById('register-username').value.trim();
227
+ const email = document.getElementById('register-email').value.trim();
228
+ const password = document.getElementById('register-password').value;
229
+
230
+ if (password.length < 8) {
231
+ registerError.textContent = 'Password must be at least 8 characters';
232
+ return;
233
+ }
234
+
235
+ try {
236
+ await auth.register(username, email, password);
237
+ hideAuthOverlay();
238
+ startApp();
239
+ } catch (err) {
240
+ registerError.textContent = err.message;
241
+ }
242
+ });
243
+
244
+ // License form
166
245
  licenseForm.addEventListener('submit', async (e) => {
167
246
  e.preventDefault();
168
247
  loginError.textContent = '';
package/public/index.html CHANGED
@@ -38,18 +38,29 @@
38
38
  <div class="auth-card-inner">
39
39
  <h1 class="logo-text">NINJA TERMINALS</h1>
40
40
  <p class="auth-subtitle">Multi-Agent Claude Code Orchestrator</p>
41
+ <!-- Login Form -->
41
42
  <form id="login-form">
42
43
  <input type="text" id="login-email" placeholder="Email or username" required autocomplete="username">
43
44
  <input type="password" id="login-password" placeholder="Password" required autocomplete="current-password">
44
45
  <button type="submit" class="auth-btn">Sign In</button>
45
46
  <p class="auth-error" id="login-error"></p>
46
47
  </form>
48
+
49
+ <!-- Register Form (hidden by default) -->
50
+ <form id="register-form" class="hidden">
51
+ <input type="text" id="register-username" placeholder="Username" required autocomplete="username">
52
+ <input type="email" id="register-email" placeholder="Email" required autocomplete="email">
53
+ <input type="password" id="register-password" placeholder="Password (min 8 chars)" required autocomplete="new-password" minlength="8">
54
+ <button type="submit" class="auth-btn">Create Account</button>
55
+ <p class="auth-error" id="register-error"></p>
56
+ </form>
57
+
47
58
  <div class="auth-divider"><span>or</span></div>
48
59
  <form id="license-form">
49
60
  <input type="text" id="license-key" placeholder="Enter license key" autocomplete="off">
50
61
  <button type="submit" class="auth-btn auth-btn-secondary">Activate License</button>
51
62
  </form>
52
- <p class="auth-footer">Don't have an account? <a href="https://ninjaterminals.com" target="_blank">Sign up</a></p>
63
+ <p class="auth-footer" id="auth-toggle-text">Don't have an account? <a href="#" id="show-register">Sign up</a></p>
53
64
  </div>
54
65
  </div>
55
66
  </div>
package/public/style.css CHANGED
@@ -1,5 +1,7 @@
1
1
  * { margin: 0; padding: 0; box-sizing: border-box; }
2
2
 
3
+ .hidden { display: none !important; }
4
+
3
5
  :root {
4
6
  /* ── Retro Command Cards palette ── */
5
7
  --bg: #0C0C0C;