ninja-terminals 2.0.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/CLAUDE.md +121 -0
- package/ORCHESTRATOR-PROMPT.md +295 -0
- package/cli.js +117 -0
- package/lib/analyze-session.js +92 -0
- package/lib/evolution-writer.js +27 -0
- package/lib/permissions.js +311 -0
- package/lib/playbook-tracker.js +85 -0
- package/lib/resilience.js +458 -0
- package/lib/ring-buffer.js +125 -0
- package/lib/safe-file-writer.js +51 -0
- package/lib/scheduler.js +212 -0
- package/lib/settings-gen.js +159 -0
- package/lib/sse.js +103 -0
- package/lib/status-detect.js +229 -0
- package/lib/task-dag.js +547 -0
- package/lib/tool-rater.js +63 -0
- package/orchestrator/evolution-log.md +33 -0
- package/orchestrator/identity.md +60 -0
- package/orchestrator/metrics/.gitkeep +0 -0
- package/orchestrator/metrics/raw/.gitkeep +0 -0
- package/orchestrator/metrics/session-2026-03-23-setup.md +54 -0
- package/orchestrator/metrics/session-2026-03-24-appcast-build.md +55 -0
- package/orchestrator/playbooks.md +71 -0
- package/orchestrator/security-protocol.md +69 -0
- package/orchestrator/tool-registry.md +96 -0
- package/package.json +46 -0
- package/public/app.js +860 -0
- package/public/index.html +60 -0
- package/public/style.css +678 -0
- package/server.js +695 -0
package/server.js
ADDED
|
@@ -0,0 +1,695 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const http = require('http');
|
|
3
|
+
const { WebSocketServer } = require('ws');
|
|
4
|
+
const pty = require('node-pty');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
// ── Lib imports ─────────────────────────────────────────────
|
|
8
|
+
const { LineBuffer, RawBuffer } = require('./lib/ring-buffer');
|
|
9
|
+
const { stripAnsi, detectStatus, extractContextPct, extractStructuredEvents } = require('./lib/status-detect');
|
|
10
|
+
const { SSEManager } = require('./lib/sse');
|
|
11
|
+
const { evaluatePermission, getDefaultRules, createEvaluateMiddleware } = require('./lib/permissions');
|
|
12
|
+
const { TaskDAG } = require('./lib/task-dag');
|
|
13
|
+
const { selectTerminal } = require('./lib/scheduler');
|
|
14
|
+
const { CircuitBreaker, RetryBudget, Supervisor, classifyError } = require('./lib/resilience');
|
|
15
|
+
const { writeWorkerSettings } = require('./lib/settings-gen');
|
|
16
|
+
const { rateTools } = require('./lib/tool-rater');
|
|
17
|
+
const { parsePlaybooks, getPlaybookUsage, promotePlaybooks } = require('./lib/playbook-tracker');
|
|
18
|
+
const { isImmutable, safeWrite, safeAppend } = require('./lib/safe-file-writer');
|
|
19
|
+
const { logEvolution } = require('./lib/evolution-writer');
|
|
20
|
+
|
|
21
|
+
// ── Config ──────────────────────────────────────────────────
|
|
22
|
+
const PORT = process.env.PORT || 3300;
|
|
23
|
+
const DEFAULT_TERMINALS = parseInt(process.env.DEFAULT_TERMINALS || '4', 10);
|
|
24
|
+
const CLAUDE_CMD = process.env.CLAUDE_CMD || 'claude --dangerously-skip-permissions';
|
|
25
|
+
const SHELL = process.env.SHELL || '/bin/zsh';
|
|
26
|
+
const PROJECT_DIR = __dirname;
|
|
27
|
+
const DEFAULT_CWD = process.env.DEFAULT_CWD || null; // Set to target project path to avoid cross-project prompts
|
|
28
|
+
|
|
29
|
+
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
|
30
|
+
|
|
31
|
+
// ── Express + WS ────────────────────────────────────────────
|
|
32
|
+
const app = express();
|
|
33
|
+
const server = http.createServer(app);
|
|
34
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
35
|
+
|
|
36
|
+
app.use(express.json());
|
|
37
|
+
app.use(express.static(path.join(__dirname, 'public')));
|
|
38
|
+
|
|
39
|
+
// ── Global State ────────────────────────────────────────────
|
|
40
|
+
let nextId = 1;
|
|
41
|
+
const terminals = new Map();
|
|
42
|
+
|
|
43
|
+
const sse = new SSEManager();
|
|
44
|
+
const taskDag = new TaskDAG();
|
|
45
|
+
const retryBudget = new RetryBudget();
|
|
46
|
+
const supervisor = new Supervisor();
|
|
47
|
+
|
|
48
|
+
// ── Helpers ─────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
function getElapsed(terminal) {
|
|
51
|
+
if (!terminal.taskStartedAt) return null;
|
|
52
|
+
const ms = Date.now() - terminal.taskStartedAt;
|
|
53
|
+
const s = Math.floor(ms / 1000);
|
|
54
|
+
if (s < 60) return `${s}s`;
|
|
55
|
+
const m = Math.floor(s / 60);
|
|
56
|
+
const rem = s % 60;
|
|
57
|
+
return `${m}m ${rem}s`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Return permission rules for a terminal, used by the evaluate middleware.
|
|
62
|
+
*/
|
|
63
|
+
function getTerminalRules(terminalId) {
|
|
64
|
+
const terminal = terminals.get(parseInt(terminalId, 10));
|
|
65
|
+
if (!terminal) return null;
|
|
66
|
+
|
|
67
|
+
// Build rules from the terminal's assigned scope
|
|
68
|
+
if (terminal.scope && terminal.scope.length > 0) {
|
|
69
|
+
// Merge default rules for each scope directory
|
|
70
|
+
const combined = { allow: [], deny: [], ask: [] };
|
|
71
|
+
for (const s of terminal.scope) {
|
|
72
|
+
const rules = getDefaultRules(s);
|
|
73
|
+
combined.allow.push(...rules.allow);
|
|
74
|
+
combined.deny.push(...rules.deny);
|
|
75
|
+
combined.ask.push(...rules.ask);
|
|
76
|
+
}
|
|
77
|
+
// Deduplicate
|
|
78
|
+
combined.allow = [...new Set(combined.allow)];
|
|
79
|
+
combined.deny = [...new Set(combined.deny)];
|
|
80
|
+
combined.ask = [...new Set(combined.ask)];
|
|
81
|
+
return combined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Unrestricted terminal — use broad defaults
|
|
85
|
+
return getDefaultRules(PROJECT_DIR);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── Terminal Spawning ───────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
function spawnTerminal(label, scope = [], cwd = null) {
|
|
91
|
+
const id = nextId++;
|
|
92
|
+
const cols = 120;
|
|
93
|
+
const rows = 30;
|
|
94
|
+
|
|
95
|
+
// Resolve working directory — custom cwd or default to PROJECT_DIR
|
|
96
|
+
const workDir = cwd || PROJECT_DIR;
|
|
97
|
+
const settingsDir = cwd || PROJECT_DIR;
|
|
98
|
+
|
|
99
|
+
// Write worker settings to the TARGET project (not ninja-terminal)
|
|
100
|
+
try {
|
|
101
|
+
writeWorkerSettings(id, settingsDir, scope, { port: PORT });
|
|
102
|
+
} catch (e) {
|
|
103
|
+
console.error(`Failed to write worker settings for terminal ${id}:`, e.message);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Clean env — strip any existing Claude vars to avoid conflicts
|
|
107
|
+
const cleanEnv = {};
|
|
108
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
109
|
+
if (v !== undefined && k !== 'CLAUDECODE' && !k.startsWith('CLAUDE_')) {
|
|
110
|
+
cleanEnv[k] = v;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const ptyProcess = pty.spawn(SHELL, [], {
|
|
115
|
+
name: 'xterm-256color',
|
|
116
|
+
cols,
|
|
117
|
+
rows,
|
|
118
|
+
cwd: workDir,
|
|
119
|
+
env: {
|
|
120
|
+
...cleanEnv,
|
|
121
|
+
TERM: 'xterm-256color',
|
|
122
|
+
HOME: require('os').homedir(),
|
|
123
|
+
PATH: `${require('os').homedir()}/.local/bin:/opt/homebrew/bin:${process.env.PATH || ''}`,
|
|
124
|
+
SHELL_SESSIONS_DISABLE: '1',
|
|
125
|
+
NINJA_TERMINAL_ID: String(id),
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// After shell starts, cd to work dir and launch claude
|
|
130
|
+
setTimeout(() => {
|
|
131
|
+
ptyProcess.write(`cd "${workDir}" && ${CLAUDE_CMD}\r`);
|
|
132
|
+
}, 500);
|
|
133
|
+
|
|
134
|
+
const terminal = {
|
|
135
|
+
id,
|
|
136
|
+
label: label || `T${id}`,
|
|
137
|
+
pty: ptyProcess,
|
|
138
|
+
clients: new Set(),
|
|
139
|
+
status: 'starting',
|
|
140
|
+
startedAt: Date.now(),
|
|
141
|
+
taskStartedAt: Date.now(),
|
|
142
|
+
lastActivity: Date.now(),
|
|
143
|
+
rawBuffer: new RawBuffer(65536),
|
|
144
|
+
lineBuffer: new LineBuffer(1000),
|
|
145
|
+
structuredLog: [],
|
|
146
|
+
cols,
|
|
147
|
+
rows,
|
|
148
|
+
taskName: null,
|
|
149
|
+
progress: null,
|
|
150
|
+
scope: Array.isArray(scope) ? scope : (scope ? [scope] : []),
|
|
151
|
+
cwd: workDir,
|
|
152
|
+
previousFiles: [],
|
|
153
|
+
lastTaskCompletedAt: null,
|
|
154
|
+
circuitBreaker: new CircuitBreaker(id),
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// ── PTY data handler ──────────────────────────────────────
|
|
158
|
+
ptyProcess.onData((data) => {
|
|
159
|
+
terminal.lastActivity = Date.now();
|
|
160
|
+
terminal.rawBuffer.push(data);
|
|
161
|
+
|
|
162
|
+
// Strip ANSI, split into lines, push to line buffer
|
|
163
|
+
const stripped = stripAnsi(data);
|
|
164
|
+
const lines = stripped.split('\n').filter(l => l.trim());
|
|
165
|
+
for (const line of lines) {
|
|
166
|
+
terminal.lineBuffer.push(line);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Extract structured events
|
|
170
|
+
const events = extractStructuredEvents(lines, terminal.label);
|
|
171
|
+
for (const evt of events) {
|
|
172
|
+
terminal.structuredLog.push(evt);
|
|
173
|
+
if (terminal.structuredLog.length > 500) terminal.structuredLog.shift();
|
|
174
|
+
sse.broadcast(evt.type, evt);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Broadcast raw to WebSocket clients
|
|
178
|
+
for (const ws of terminal.clients) {
|
|
179
|
+
if (ws.readyState === 1) ws.send(data);
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
ptyProcess.onExit(({ exitCode }) => {
|
|
184
|
+
terminal.status = 'exited';
|
|
185
|
+
console.log(`Terminal ${id} exited with code ${exitCode}`);
|
|
186
|
+
sse.broadcast('status_change', {
|
|
187
|
+
terminal: terminal.label,
|
|
188
|
+
id: terminal.id,
|
|
189
|
+
from: terminal.status,
|
|
190
|
+
to: 'exited',
|
|
191
|
+
elapsed: getElapsed(terminal),
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
terminals.set(id, terminal);
|
|
196
|
+
console.log(`Spawned terminal ${id} (${terminal.label})${scope.length ? ` scope: ${scope}` : ''}`);
|
|
197
|
+
return terminal;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ── Status Detection Loop (2s) ──────────────────────────────
|
|
201
|
+
|
|
202
|
+
setInterval(() => {
|
|
203
|
+
for (const [, terminal] of terminals) {
|
|
204
|
+
if (terminal.status === 'exited') continue;
|
|
205
|
+
const prev = terminal.status;
|
|
206
|
+
const recentLines = terminal.lineBuffer.last(50);
|
|
207
|
+
const newStatus = detectStatus(recentLines);
|
|
208
|
+
|
|
209
|
+
if (newStatus !== prev) {
|
|
210
|
+
terminal.status = newStatus;
|
|
211
|
+
sse.broadcast('status_change', {
|
|
212
|
+
terminal: terminal.label,
|
|
213
|
+
id: terminal.id,
|
|
214
|
+
from: prev,
|
|
215
|
+
to: newStatus,
|
|
216
|
+
elapsed: getElapsed(terminal),
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// Track task timing
|
|
220
|
+
if (newStatus === 'working' && prev !== 'working') {
|
|
221
|
+
terminal.taskStartedAt = Date.now();
|
|
222
|
+
}
|
|
223
|
+
if (newStatus === 'done' || newStatus === 'idle') {
|
|
224
|
+
terminal.taskStartedAt = null;
|
|
225
|
+
if (newStatus === 'done') {
|
|
226
|
+
terminal.lastTaskCompletedAt = Date.now();
|
|
227
|
+
terminal.circuitBreaker.recordSuccess();
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (newStatus === 'error') {
|
|
231
|
+
terminal.circuitBreaker.recordFailure();
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Context window check
|
|
236
|
+
const ctx = extractContextPct(recentLines);
|
|
237
|
+
if (ctx && ctx > 80) {
|
|
238
|
+
sse.broadcast('context_low', {
|
|
239
|
+
terminal: terminal.label,
|
|
240
|
+
id: terminal.id,
|
|
241
|
+
usage: ctx,
|
|
242
|
+
threshold: 80,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}, 2000);
|
|
247
|
+
|
|
248
|
+
// ── WebSocket Upgrade ───────────────────────────────────────
|
|
249
|
+
|
|
250
|
+
server.on('upgrade', (req, socket, head) => {
|
|
251
|
+
const match = req.url.match(/^\/ws\/(\d+)$/);
|
|
252
|
+
if (!match) {
|
|
253
|
+
socket.destroy();
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const id = parseInt(match[1], 10);
|
|
258
|
+
const terminal = terminals.get(id);
|
|
259
|
+
if (!terminal || terminal.status === 'exited') {
|
|
260
|
+
socket.destroy();
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
265
|
+
terminal.clients.add(ws);
|
|
266
|
+
|
|
267
|
+
// Send buffered raw output so client catches up
|
|
268
|
+
const buffered = terminal.rawBuffer.getAll();
|
|
269
|
+
if (buffered) {
|
|
270
|
+
ws.send(buffered);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
ws.on('message', (msg) => {
|
|
274
|
+
const data = msg.toString();
|
|
275
|
+
// Check for resize message
|
|
276
|
+
try {
|
|
277
|
+
const parsed = JSON.parse(data);
|
|
278
|
+
if (parsed.type === 'resize' && parsed.cols && parsed.rows) {
|
|
279
|
+
const c = Math.max(1, Math.min(500, parsed.cols));
|
|
280
|
+
const r = Math.max(1, Math.min(200, parsed.rows));
|
|
281
|
+
terminal.pty.resize(c, r);
|
|
282
|
+
terminal.cols = c;
|
|
283
|
+
terminal.rows = r;
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
} catch { /* not JSON, treat as input */ }
|
|
287
|
+
terminal.pty.write(data);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
ws.on('close', () => {
|
|
291
|
+
terminal.clients.delete(ws);
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
// ── API Routes ──────────────────────────────────────────────
|
|
297
|
+
|
|
298
|
+
// Health
|
|
299
|
+
app.get('/health', (_req, res) => {
|
|
300
|
+
res.json({
|
|
301
|
+
status: 'ok',
|
|
302
|
+
version: '2.0.0',
|
|
303
|
+
terminals: terminals.size,
|
|
304
|
+
sseClients: sse.clientCount,
|
|
305
|
+
uptime: process.uptime(),
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// List terminals
|
|
310
|
+
app.get('/api/terminals', (req, res) => {
|
|
311
|
+
const list = [];
|
|
312
|
+
for (const [, t] of terminals) {
|
|
313
|
+
const recentLines = t.lineBuffer.last(50);
|
|
314
|
+
const entry = {
|
|
315
|
+
id: t.id,
|
|
316
|
+
label: t.label,
|
|
317
|
+
status: t.status,
|
|
318
|
+
elapsed: getElapsed(t),
|
|
319
|
+
contextPct: extractContextPct(recentLines),
|
|
320
|
+
cols: t.cols,
|
|
321
|
+
rows: t.rows,
|
|
322
|
+
taskName: t.taskName,
|
|
323
|
+
progress: t.progress,
|
|
324
|
+
scope: t.scope,
|
|
325
|
+
};
|
|
326
|
+
if (req.query.debug) {
|
|
327
|
+
entry.lastLines = recentLines.slice(-10);
|
|
328
|
+
}
|
|
329
|
+
list.push(entry);
|
|
330
|
+
}
|
|
331
|
+
res.json(list);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
// Spawn terminal
|
|
335
|
+
app.post('/api/terminals', (req, res) => {
|
|
336
|
+
try {
|
|
337
|
+
const label = req.body?.label;
|
|
338
|
+
const scope = req.body?.scope || [];
|
|
339
|
+
const cwd = req.body?.cwd || null;
|
|
340
|
+
const terminal = spawnTerminal(label, scope, cwd);
|
|
341
|
+
res.json({ id: terminal.id, label: terminal.label, status: terminal.status, scope: terminal.scope, cwd: terminal.cwd });
|
|
342
|
+
} catch (err) {
|
|
343
|
+
res.status(500).json({ error: 'Failed to spawn terminal', detail: err.message });
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// Delete terminal
|
|
348
|
+
app.delete('/api/terminals/:id', (req, res) => {
|
|
349
|
+
const id = parseInt(req.params.id, 10);
|
|
350
|
+
const terminal = terminals.get(id);
|
|
351
|
+
if (!terminal) return res.status(404).json({ error: 'Not found' });
|
|
352
|
+
|
|
353
|
+
terminal.pty.kill();
|
|
354
|
+
for (const ws of terminal.clients) ws.close();
|
|
355
|
+
terminals.delete(id);
|
|
356
|
+
res.json({ ok: true });
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
// Restart terminal
|
|
360
|
+
app.post('/api/terminals/:id/restart', (req, res) => {
|
|
361
|
+
const id = parseInt(req.params.id, 10);
|
|
362
|
+
const terminal = terminals.get(id);
|
|
363
|
+
if (!terminal) return res.status(404).json({ error: 'Not found' });
|
|
364
|
+
|
|
365
|
+
const label = terminal.label;
|
|
366
|
+
const scope = terminal.scope;
|
|
367
|
+
const termCwd = terminal.cwd;
|
|
368
|
+
terminal.pty.kill();
|
|
369
|
+
for (const ws of terminal.clients) ws.close();
|
|
370
|
+
terminals.delete(id);
|
|
371
|
+
|
|
372
|
+
const newTerminal = spawnTerminal(label, scope, termCwd);
|
|
373
|
+
res.json({ id: newTerminal.id, label: newTerminal.label, status: newTerminal.status });
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
// Send input
|
|
377
|
+
app.post('/api/terminals/:id/input', (req, res) => {
|
|
378
|
+
const id = parseInt(req.params.id, 10);
|
|
379
|
+
const terminal = terminals.get(id);
|
|
380
|
+
if (!terminal) return res.status(404).json({ error: 'Not found' });
|
|
381
|
+
|
|
382
|
+
const text = req.body?.text;
|
|
383
|
+
if (!text) return res.status(400).json({ error: 'text required' });
|
|
384
|
+
|
|
385
|
+
terminal.pty.write(text);
|
|
386
|
+
res.json({ ok: true });
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
// Set label
|
|
390
|
+
app.post('/api/terminals/:id/label', (req, res) => {
|
|
391
|
+
const id = parseInt(req.params.id, 10);
|
|
392
|
+
const terminal = terminals.get(id);
|
|
393
|
+
if (!terminal) return res.status(404).json({ error: 'Not found' });
|
|
394
|
+
|
|
395
|
+
terminal.label = req.body?.label || terminal.label;
|
|
396
|
+
res.json({ ok: true, label: terminal.label });
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
// Get status
|
|
400
|
+
app.get('/api/terminals/:id/status', (req, res) => {
|
|
401
|
+
const id = parseInt(req.params.id, 10);
|
|
402
|
+
const terminal = terminals.get(id);
|
|
403
|
+
if (!terminal) return res.status(404).json({ error: 'Not found' });
|
|
404
|
+
|
|
405
|
+
const recentLines = terminal.lineBuffer.last(50);
|
|
406
|
+
res.json({
|
|
407
|
+
id: terminal.id,
|
|
408
|
+
label: terminal.label,
|
|
409
|
+
status: terminal.status,
|
|
410
|
+
elapsed: getElapsed(terminal),
|
|
411
|
+
contextPct: extractContextPct(recentLines),
|
|
412
|
+
taskName: terminal.taskName,
|
|
413
|
+
progress: terminal.progress,
|
|
414
|
+
scope: terminal.scope,
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
// Paginated output
|
|
419
|
+
app.get('/api/terminals/:id/output', (req, res) => {
|
|
420
|
+
const id = parseInt(req.params.id, 10);
|
|
421
|
+
const terminal = terminals.get(id);
|
|
422
|
+
if (!terminal) return res.status(404).json({ error: 'Not found' });
|
|
423
|
+
|
|
424
|
+
const lines = parseInt(req.query.lines) || 50;
|
|
425
|
+
const offset = parseInt(req.query.offset) || 0;
|
|
426
|
+
const result = terminal.lineBuffer.slice(offset, lines);
|
|
427
|
+
res.json(result);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
// Structured log
|
|
431
|
+
app.get('/api/terminals/:id/log', (req, res) => {
|
|
432
|
+
const id = parseInt(req.params.id, 10);
|
|
433
|
+
const terminal = terminals.get(id);
|
|
434
|
+
if (!terminal) return res.status(404).json({ error: 'Not found' });
|
|
435
|
+
|
|
436
|
+
res.json(terminal.structuredLog);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
// Graceful kill
|
|
440
|
+
app.post('/api/terminals/:id/kill', async (req, res) => {
|
|
441
|
+
const id = parseInt(req.params.id, 10);
|
|
442
|
+
const terminal = terminals.get(id);
|
|
443
|
+
if (!terminal) return res.status(404).json({ error: 'Not found' });
|
|
444
|
+
|
|
445
|
+
if (terminal.status === 'exited') {
|
|
446
|
+
return res.json({ ok: true, message: 'Already exited' });
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Graduated: SIGINT -> wait 5s -> SIGTERM -> wait 3s -> SIGKILL
|
|
450
|
+
terminal.pty.kill('SIGINT');
|
|
451
|
+
await sleep(5000);
|
|
452
|
+
if (terminal.status !== 'exited') {
|
|
453
|
+
terminal.pty.kill('SIGTERM');
|
|
454
|
+
await sleep(3000);
|
|
455
|
+
if (terminal.status !== 'exited') {
|
|
456
|
+
terminal.pty.kill('SIGKILL');
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
res.json({ ok: true });
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
// Permission evaluation hook endpoint
|
|
463
|
+
app.post('/api/terminals/:id/evaluate', createEvaluateMiddleware(getTerminalRules));
|
|
464
|
+
|
|
465
|
+
// Worker stopped hook endpoint
|
|
466
|
+
app.post('/api/terminals/:id/stopped', (req, res) => {
|
|
467
|
+
const id = parseInt(req.params.id, 10);
|
|
468
|
+
const terminal = terminals.get(id);
|
|
469
|
+
if (!terminal) return res.status(404).json({ error: 'Not found' });
|
|
470
|
+
|
|
471
|
+
console.log(`Terminal ${id} (${terminal.label}) stopped via hook`);
|
|
472
|
+
sse.broadcast('worker_stopped', {
|
|
473
|
+
terminal: terminal.label,
|
|
474
|
+
id: terminal.id,
|
|
475
|
+
ts: new Date().toISOString(),
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
res.json({ ok: true });
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
// Context compacted hook endpoint
|
|
482
|
+
app.post('/api/terminals/:id/compacted', (req, res) => {
|
|
483
|
+
const id = parseInt(req.params.id, 10);
|
|
484
|
+
const terminal = terminals.get(id);
|
|
485
|
+
if (!terminal) return res.status(404).json({ error: 'Not found' });
|
|
486
|
+
|
|
487
|
+
console.log(`Terminal ${id} (${terminal.label}) context compacted`);
|
|
488
|
+
sse.broadcast('context_compacted', {
|
|
489
|
+
terminal: terminal.label,
|
|
490
|
+
id: terminal.id,
|
|
491
|
+
ts: new Date().toISOString(),
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
res.json({ ok: true });
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
// Assign task to terminal
|
|
498
|
+
app.post('/api/terminals/:id/task', (req, res) => {
|
|
499
|
+
const id = parseInt(req.params.id, 10);
|
|
500
|
+
const terminal = terminals.get(id);
|
|
501
|
+
if (!terminal) return res.status(404).json({ error: 'Not found' });
|
|
502
|
+
|
|
503
|
+
const { name, description, scope } = req.body || {};
|
|
504
|
+
if (!name) return res.status(400).json({ error: 'name required' });
|
|
505
|
+
|
|
506
|
+
terminal.taskName = name;
|
|
507
|
+
terminal.progress = null;
|
|
508
|
+
terminal.taskStartedAt = Date.now();
|
|
509
|
+
|
|
510
|
+
if (scope) {
|
|
511
|
+
terminal.scope = Array.isArray(scope) ? scope : [scope];
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
sse.broadcast('task_assigned', {
|
|
515
|
+
terminal: terminal.label,
|
|
516
|
+
id: terminal.id,
|
|
517
|
+
taskName: name,
|
|
518
|
+
description: description || null,
|
|
519
|
+
scope: terminal.scope,
|
|
520
|
+
ts: new Date().toISOString(),
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
// Send the task as input to the terminal
|
|
524
|
+
if (description) {
|
|
525
|
+
terminal.pty.write(`${description}\r`);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
res.json({ ok: true, taskName: name, scope: terminal.scope });
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
// ── SSE Events ──────────────────────────────────────────────
|
|
532
|
+
|
|
533
|
+
app.get('/api/events', (req, res) => {
|
|
534
|
+
sse.addClient(res);
|
|
535
|
+
req.on('close', () => sse.removeClient(res));
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
// ── Task DAG ────────────────────────────────────────────────
|
|
539
|
+
|
|
540
|
+
app.get('/api/tasks', (_req, res) => {
|
|
541
|
+
try {
|
|
542
|
+
res.json(taskDag.toJSON());
|
|
543
|
+
} catch (err) {
|
|
544
|
+
res.status(500).json({ error: 'Failed to serialize task DAG', detail: err.message });
|
|
545
|
+
}
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
app.post('/api/tasks', (req, res) => {
|
|
549
|
+
try {
|
|
550
|
+
const { id, name, description, dependencies, scope } = req.body || {};
|
|
551
|
+
if (!id || !name) return res.status(400).json({ error: 'id and name required' });
|
|
552
|
+
|
|
553
|
+
taskDag.addTask({ id, name, description, dependencies, scope });
|
|
554
|
+
sse.broadcast('task_added', { id, name, description, dependencies, scope, ts: new Date().toISOString() });
|
|
555
|
+
res.json({ ok: true, task: { id, name } });
|
|
556
|
+
} catch (err) {
|
|
557
|
+
res.status(400).json({ error: err.message });
|
|
558
|
+
}
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
app.delete('/api/tasks/:id', (req, res) => {
|
|
562
|
+
try {
|
|
563
|
+
taskDag.removeTask(req.params.id);
|
|
564
|
+
sse.broadcast('task_removed', { id: req.params.id, ts: new Date().toISOString() });
|
|
565
|
+
res.json({ ok: true });
|
|
566
|
+
} catch (err) {
|
|
567
|
+
res.status(400).json({ error: err.message });
|
|
568
|
+
}
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
// ── Self-Improvement Metrics API ────────────────────────────
|
|
572
|
+
|
|
573
|
+
app.get('/api/metrics/tools', async (_req, res) => {
|
|
574
|
+
try {
|
|
575
|
+
const ratings = await rateTools();
|
|
576
|
+
const result = {};
|
|
577
|
+
for (const [tool, data] of ratings) result[tool] = data;
|
|
578
|
+
res.json(result);
|
|
579
|
+
} catch (err) {
|
|
580
|
+
res.status(500).json({ error: 'Failed to compute tool ratings', detail: err.message });
|
|
581
|
+
}
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
app.get('/api/metrics/sessions', (req, res) => {
|
|
585
|
+
try {
|
|
586
|
+
const summariesPath = path.join(__dirname, 'orchestrator', 'metrics', 'summaries.ndjson');
|
|
587
|
+
const fs = require('fs');
|
|
588
|
+
if (!fs.existsSync(summariesPath)) return res.json([]);
|
|
589
|
+
const lines = fs.readFileSync(summariesPath, 'utf8').trim().split('\n').filter(Boolean);
|
|
590
|
+
const limit = parseInt(req.query?.limit) || 50;
|
|
591
|
+
const summaries = lines.slice(-limit).map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
|
592
|
+
res.json(summaries);
|
|
593
|
+
} catch (err) {
|
|
594
|
+
res.status(500).json({ error: 'Failed to read sessions', detail: err.message });
|
|
595
|
+
}
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
app.get('/api/metrics/friction', (_req, res) => {
|
|
599
|
+
try {
|
|
600
|
+
const summariesPath = path.join(__dirname, 'orchestrator', 'metrics', 'summaries.ndjson');
|
|
601
|
+
const fs = require('fs');
|
|
602
|
+
if (!fs.existsSync(summariesPath)) return res.json({ friction_points: [], total_sessions: 0 });
|
|
603
|
+
const lines = fs.readFileSync(summariesPath, 'utf8').trim().split('\n').filter(Boolean);
|
|
604
|
+
const sessions = lines.map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
|
605
|
+
|
|
606
|
+
const toolAgg = {};
|
|
607
|
+
for (const s of sessions) {
|
|
608
|
+
if (!s.tools) continue;
|
|
609
|
+
for (const [tool, stats] of Object.entries(s.tools)) {
|
|
610
|
+
if (!toolAgg[tool]) toolAgg[tool] = { failures: 0, invocations: 0, sessions: 0 };
|
|
611
|
+
toolAgg[tool].failures += stats.failures || 0;
|
|
612
|
+
toolAgg[tool].invocations += stats.invocations || 0;
|
|
613
|
+
toolAgg[tool].sessions++;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const friction = Object.entries(toolAgg)
|
|
618
|
+
.filter(([, v]) => v.failures > 0 && v.sessions >= 2)
|
|
619
|
+
.map(([tool, v]) => ({
|
|
620
|
+
tool,
|
|
621
|
+
failure_rate: v.invocations > 0 ? +(v.failures / v.invocations).toFixed(3) : 0,
|
|
622
|
+
total_failures: v.failures,
|
|
623
|
+
across_sessions: v.sessions,
|
|
624
|
+
}))
|
|
625
|
+
.sort((a, b) => b.failure_rate - a.failure_rate);
|
|
626
|
+
|
|
627
|
+
res.json({ friction_points: friction, total_sessions: sessions.length });
|
|
628
|
+
} catch (err) {
|
|
629
|
+
res.status(500).json({ error: 'Failed to compute friction', detail: err.message });
|
|
630
|
+
}
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
app.post('/api/orchestrator/evolve', (req, res) => {
|
|
634
|
+
try {
|
|
635
|
+
const { action, target, content, reason, evidence } = req.body || {};
|
|
636
|
+
if (!action || !target) return res.status(400).json({ error: 'action and target required' });
|
|
637
|
+
|
|
638
|
+
const allowedTargets = ['playbooks.md', 'tool-registry.md'];
|
|
639
|
+
const targetBase = path.basename(target);
|
|
640
|
+
if (!allowedTargets.includes(targetBase)) {
|
|
641
|
+
return res.status(403).json({ error: `Can only evolve: ${allowedTargets.join(', ')}` });
|
|
642
|
+
}
|
|
643
|
+
if (isImmutable(target)) {
|
|
644
|
+
return res.status(403).json({ error: `${targetBase} is immutable` });
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
const targetPath = path.join(__dirname, 'orchestrator', targetBase);
|
|
648
|
+
|
|
649
|
+
if (action === 'append') safeAppend(targetPath, '\n' + content);
|
|
650
|
+
else if (action === 'replace') safeWrite(targetPath, content);
|
|
651
|
+
else return res.status(400).json({ error: 'action must be "append" or "replace"' });
|
|
652
|
+
|
|
653
|
+
logEvolution({
|
|
654
|
+
file: `orchestrator/${targetBase}`,
|
|
655
|
+
change: (content || '').substring(0, 200),
|
|
656
|
+
why: reason || 'No reason provided',
|
|
657
|
+
evidence: evidence || 'No evidence provided',
|
|
658
|
+
reversible: 'yes',
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
sse.broadcast('evolution', { action, target: targetBase, reason, ts: new Date().toISOString() });
|
|
662
|
+
res.json({ ok: true, action, target: targetBase });
|
|
663
|
+
} catch (err) {
|
|
664
|
+
res.status(500).json({ error: 'Evolution failed', detail: err.message });
|
|
665
|
+
}
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
app.get('/api/metrics/playbooks', (_req, res) => {
|
|
669
|
+
try {
|
|
670
|
+
const playbooksPath = path.join(__dirname, 'orchestrator', 'playbooks.md');
|
|
671
|
+
const summariesPath = path.join(__dirname, 'orchestrator', 'metrics', 'summaries.ndjson');
|
|
672
|
+
const parsed = parsePlaybooks(playbooksPath);
|
|
673
|
+
const usage = getPlaybookUsage(summariesPath);
|
|
674
|
+
const promotions = promotePlaybooks(playbooksPath, summariesPath);
|
|
675
|
+
res.json({ playbooks: parsed, usage: Object.fromEntries(usage), promotions });
|
|
676
|
+
} catch (err) {
|
|
677
|
+
res.status(500).json({ error: 'Failed to analyze playbooks', detail: err.message });
|
|
678
|
+
}
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
// ── Start ───────────────────────────────────────────────────
|
|
682
|
+
|
|
683
|
+
server.listen(PORT, () => {
|
|
684
|
+
console.log(`Ninja Terminals v2 running on http://localhost:${PORT}`);
|
|
685
|
+
|
|
686
|
+
// Start SSE heartbeat
|
|
687
|
+
sse.startHeartbeat(15000);
|
|
688
|
+
|
|
689
|
+
// Spawn default terminals
|
|
690
|
+
for (let i = 0; i < DEFAULT_TERMINALS; i++) {
|
|
691
|
+
spawnTerminal(`T${i + 1}`, [], DEFAULT_CWD);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
console.log(`Spawned ${DEFAULT_TERMINALS} terminals with Claude Code`);
|
|
695
|
+
});
|