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 +156 -0
- package/mcp-server.js +712 -0
- package/package.json +12 -5
- package/public/app.js +79 -0
- package/public/index.html +12 -1
- package/public/style.css +2 -0
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.
|
|
4
|
-
"description": "
|
|
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="
|
|
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>
|