ninja-terminals 2.0.0 → 2.1.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 +2 -17
- package/cli.js +23 -0
- package/lib/auth.js +195 -0
- package/lib/hypothesis-validator.js +346 -0
- package/lib/post-session.js +426 -0
- package/lib/pre-dispatch.js +265 -0
- package/lib/prompt-delivery.js +127 -0
- package/lib/settings-gen.js +82 -23
- package/package.json +8 -6
- package/public/app.js +282 -13
- package/public/index.html +45 -0
- package/public/style.css +300 -0
- package/server.js +358 -33
- package/ORCHESTRATOR-PROMPT.md +0 -295
- package/orchestrator/evolution-log.md +0 -33
- package/orchestrator/identity.md +0 -60
- package/orchestrator/metrics/.gitkeep +0 -0
- package/orchestrator/metrics/raw/.gitkeep +0 -0
- package/orchestrator/metrics/session-2026-03-23-setup.md +0 -54
- package/orchestrator/metrics/session-2026-03-24-appcast-build.md +0 -55
- package/orchestrator/playbooks.md +0 -71
- package/orchestrator/security-protocol.md +0 -69
- package/orchestrator/tool-registry.md +0 -96
package/server.js
CHANGED
|
@@ -14,9 +14,12 @@ const { selectTerminal } = require('./lib/scheduler');
|
|
|
14
14
|
const { CircuitBreaker, RetryBudget, Supervisor, classifyError } = require('./lib/resilience');
|
|
15
15
|
const { writeWorkerSettings } = require('./lib/settings-gen');
|
|
16
16
|
const { rateTools } = require('./lib/tool-rater');
|
|
17
|
+
const { createAuthMiddleware, validateWebSocketToken, startSessionHeartbeat } = require('./lib/auth');
|
|
17
18
|
const { parsePlaybooks, getPlaybookUsage, promotePlaybooks } = require('./lib/playbook-tracker');
|
|
18
19
|
const { isImmutable, safeWrite, safeAppend } = require('./lib/safe-file-writer');
|
|
19
20
|
const { logEvolution } = require('./lib/evolution-writer');
|
|
21
|
+
const { getPreDispatchContext, formatContextForInjection } = require('./lib/pre-dispatch');
|
|
22
|
+
const { runPostSession } = require('./lib/post-session');
|
|
20
23
|
|
|
21
24
|
// ── Config ──────────────────────────────────────────────────
|
|
22
25
|
const PORT = process.env.PORT || 3300;
|
|
@@ -25,6 +28,7 @@ const CLAUDE_CMD = process.env.CLAUDE_CMD || 'claude --dangerously-skip-permissi
|
|
|
25
28
|
const SHELL = process.env.SHELL || '/bin/zsh';
|
|
26
29
|
const PROJECT_DIR = __dirname;
|
|
27
30
|
const DEFAULT_CWD = process.env.DEFAULT_CWD || null; // Set to target project path to avoid cross-project prompts
|
|
31
|
+
const INJECT_GUIDANCE = process.env.INJECT_GUIDANCE !== 'false'; // Default true, set INJECT_GUIDANCE=false to disable
|
|
28
32
|
|
|
29
33
|
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
|
30
34
|
|
|
@@ -40,6 +44,22 @@ app.use(express.static(path.join(__dirname, 'public')));
|
|
|
40
44
|
let nextId = 1;
|
|
41
45
|
const terminals = new Map();
|
|
42
46
|
|
|
47
|
+
// Session state for auth
|
|
48
|
+
const sessionCache = new Map(); // token -> { tier, terminalsMax, features, validatedAt }
|
|
49
|
+
let activeSession = null; // { token, tier, terminalsMax, features, terminalIds: [] }
|
|
50
|
+
|
|
51
|
+
// Auth middleware — skip for static, health, and public endpoints
|
|
52
|
+
const authMiddleware = createAuthMiddleware(sessionCache);
|
|
53
|
+
const requireAuth = (req, res, next) => {
|
|
54
|
+
// Skip auth for these paths
|
|
55
|
+
const skipPaths = ['/', '/health', '/api/events'];
|
|
56
|
+
if (skipPaths.includes(req.path)) {
|
|
57
|
+
return next();
|
|
58
|
+
}
|
|
59
|
+
// Apply auth
|
|
60
|
+
return authMiddleware(req, res, next);
|
|
61
|
+
};
|
|
62
|
+
|
|
43
63
|
const sse = new SSEManager();
|
|
44
64
|
const taskDag = new TaskDAG();
|
|
45
65
|
const retryBudget = new RetryBudget();
|
|
@@ -87,7 +107,7 @@ function getTerminalRules(terminalId) {
|
|
|
87
107
|
|
|
88
108
|
// ── Terminal Spawning ───────────────────────────────────────
|
|
89
109
|
|
|
90
|
-
function spawnTerminal(label, scope = [], cwd = null) {
|
|
110
|
+
function spawnTerminal(label, scope = [], cwd = null, tier = 'pro') {
|
|
91
111
|
const id = nextId++;
|
|
92
112
|
const cols = 120;
|
|
93
113
|
const rows = 30;
|
|
@@ -98,7 +118,7 @@ function spawnTerminal(label, scope = [], cwd = null) {
|
|
|
98
118
|
|
|
99
119
|
// Write worker settings to the TARGET project (not ninja-terminal)
|
|
100
120
|
try {
|
|
101
|
-
writeWorkerSettings(id, settingsDir, scope, { port: PORT });
|
|
121
|
+
writeWorkerSettings(id, settingsDir, scope, { port: PORT, tier });
|
|
102
122
|
} catch (e) {
|
|
103
123
|
console.error(`Failed to write worker settings for terminal ${id}:`, e.message);
|
|
104
124
|
}
|
|
@@ -247,13 +267,30 @@ setInterval(() => {
|
|
|
247
267
|
|
|
248
268
|
// ── WebSocket Upgrade ───────────────────────────────────────
|
|
249
269
|
|
|
250
|
-
server.on('upgrade', (req, socket, head) => {
|
|
251
|
-
|
|
270
|
+
server.on('upgrade', async (req, socket, head) => {
|
|
271
|
+
// Parse URL for terminal ID and token
|
|
272
|
+
const urlParts = new URL(req.url, `http://${req.headers.host}`);
|
|
273
|
+
const match = urlParts.pathname.match(/^\/ws\/(\d+)$/);
|
|
252
274
|
if (!match) {
|
|
253
275
|
socket.destroy();
|
|
254
276
|
return;
|
|
255
277
|
}
|
|
256
278
|
|
|
279
|
+
// Validate token from query param
|
|
280
|
+
const token = urlParts.searchParams.get('token');
|
|
281
|
+
if (!token) {
|
|
282
|
+
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
|
283
|
+
socket.destroy();
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const validation = await validateWebSocketToken(token, sessionCache);
|
|
288
|
+
if (!validation.valid) {
|
|
289
|
+
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
|
290
|
+
socket.destroy();
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
|
|
257
294
|
const id = parseInt(match[1], 10);
|
|
258
295
|
const terminal = terminals.get(id);
|
|
259
296
|
if (!terminal || terminal.status === 'exited') {
|
|
@@ -303,11 +340,194 @@ app.get('/health', (_req, res) => {
|
|
|
303
340
|
terminals: terminals.size,
|
|
304
341
|
sseClients: sse.clientCount,
|
|
305
342
|
uptime: process.uptime(),
|
|
343
|
+
session: activeSession ? { tier: activeSession.tier, terminalsMax: activeSession.terminalsMax } : null,
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// ── Session Endpoints ───────────────────────────────────────
|
|
348
|
+
|
|
349
|
+
// Create session — validates token and spawns terminals
|
|
350
|
+
app.post('/api/session', requireAuth, (req, res) => {
|
|
351
|
+
try {
|
|
352
|
+
const { tier, terminalsMax, features, token } = req.ninjaUser;
|
|
353
|
+
|
|
354
|
+
// Clear any existing session
|
|
355
|
+
if (activeSession) {
|
|
356
|
+
// Kill existing terminals
|
|
357
|
+
for (const id of activeSession.terminalIds) {
|
|
358
|
+
const terminal = terminals.get(id);
|
|
359
|
+
if (terminal) {
|
|
360
|
+
terminal.pty.kill();
|
|
361
|
+
for (const ws of terminal.clients) ws.close();
|
|
362
|
+
terminals.delete(id);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Create new session
|
|
368
|
+
activeSession = {
|
|
369
|
+
token,
|
|
370
|
+
tier,
|
|
371
|
+
terminalsMax,
|
|
372
|
+
features,
|
|
373
|
+
terminalIds: [],
|
|
374
|
+
createdAt: Date.now(),
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
// Spawn terminals up to the tier limit
|
|
378
|
+
const cwd = req.body?.cwd || DEFAULT_CWD;
|
|
379
|
+
const spawnedTerminals = [];
|
|
380
|
+
|
|
381
|
+
for (let i = 0; i < terminalsMax; i++) {
|
|
382
|
+
const terminal = spawnTerminal(`T${i + 1}`, [], cwd, tier);
|
|
383
|
+
activeSession.terminalIds.push(terminal.id);
|
|
384
|
+
spawnedTerminals.push({
|
|
385
|
+
id: terminal.id,
|
|
386
|
+
label: terminal.label,
|
|
387
|
+
status: terminal.status,
|
|
388
|
+
cwd: terminal.cwd,
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
console.log(`[session] Created session: tier=${tier}, terminals=${terminalsMax}`);
|
|
393
|
+
|
|
394
|
+
res.json({
|
|
395
|
+
tier,
|
|
396
|
+
terminalsMax,
|
|
397
|
+
features,
|
|
398
|
+
terminals: spawnedTerminals,
|
|
399
|
+
});
|
|
400
|
+
} catch (err) {
|
|
401
|
+
res.status(500).json({ error: 'Failed to create session', detail: err.message });
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
// Delete session — kills all terminals
|
|
406
|
+
app.delete('/api/session', requireAuth, async (req, res) => {
|
|
407
|
+
try {
|
|
408
|
+
if (!activeSession) {
|
|
409
|
+
return res.json({ ok: true, message: 'No active session' });
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Kill all session terminals
|
|
413
|
+
for (const id of activeSession.terminalIds) {
|
|
414
|
+
const terminal = terminals.get(id);
|
|
415
|
+
if (terminal && terminal.status !== 'exited') {
|
|
416
|
+
terminal.pty.kill('SIGTERM');
|
|
417
|
+
await sleep(1000);
|
|
418
|
+
if (terminal.status !== 'exited') {
|
|
419
|
+
terminal.pty.kill('SIGKILL');
|
|
420
|
+
}
|
|
421
|
+
for (const ws of terminal.clients) ws.close();
|
|
422
|
+
terminals.delete(id);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Clear session
|
|
427
|
+
const sessionTier = activeSession.tier;
|
|
428
|
+
sessionCache.delete(activeSession.token);
|
|
429
|
+
activeSession = null;
|
|
430
|
+
|
|
431
|
+
console.log(`[session] Destroyed session: tier=${sessionTier}`);
|
|
432
|
+
|
|
433
|
+
res.json({ ok: true });
|
|
434
|
+
} catch (err) {
|
|
435
|
+
res.status(500).json({ error: 'Failed to delete session', detail: err.message });
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
// Get session info
|
|
440
|
+
app.get('/api/session', requireAuth, (req, res) => {
|
|
441
|
+
if (!activeSession) {
|
|
442
|
+
return res.status(404).json({ error: 'No active session' });
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const sessionTerminals = activeSession.terminalIds.map(id => {
|
|
446
|
+
const t = terminals.get(id);
|
|
447
|
+
if (!t) return null;
|
|
448
|
+
return {
|
|
449
|
+
id: t.id,
|
|
450
|
+
label: t.label,
|
|
451
|
+
status: t.status,
|
|
452
|
+
elapsed: getElapsed(t),
|
|
453
|
+
};
|
|
454
|
+
}).filter(Boolean);
|
|
455
|
+
|
|
456
|
+
res.json({
|
|
457
|
+
tier: activeSession.tier,
|
|
458
|
+
terminalsMax: activeSession.terminalsMax,
|
|
459
|
+
features: activeSession.features,
|
|
460
|
+
terminals: sessionTerminals,
|
|
461
|
+
createdAt: activeSession.createdAt,
|
|
306
462
|
});
|
|
307
463
|
});
|
|
308
464
|
|
|
465
|
+
// End session — triggers post-session automation (analysis, ratings, hypothesis validation)
|
|
466
|
+
app.post('/api/session/end', requireAuth, async (req, res) => {
|
|
467
|
+
try {
|
|
468
|
+
console.log('[session/end] Triggering post-session automation...');
|
|
469
|
+
|
|
470
|
+
// Run the full post-session pipeline
|
|
471
|
+
const result = await runPostSession();
|
|
472
|
+
|
|
473
|
+
// Broadcast completion event
|
|
474
|
+
sse.broadcast('session_end', {
|
|
475
|
+
filesProcessed: result.filesProcessed,
|
|
476
|
+
toolsRated: Object.keys(result.toolRatings).length,
|
|
477
|
+
hypothesesPromoted: result.hypothesisValidation.promoted,
|
|
478
|
+
hypothesesRejected: result.hypothesisValidation.rejected,
|
|
479
|
+
duration_ms: result.duration_ms,
|
|
480
|
+
ts: result.ts,
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
console.log(`[session/end] Completed: ${result.filesProcessed} files, ${result.hypothesisValidation.promoted.length} promoted, ${result.hypothesisValidation.rejected.length} rejected`);
|
|
484
|
+
|
|
485
|
+
res.json(result);
|
|
486
|
+
} catch (err) {
|
|
487
|
+
console.error('[session/end] Failed:', err.message);
|
|
488
|
+
res.status(500).json({ error: 'Post-session automation failed', detail: err.message });
|
|
489
|
+
}
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
// Get latest learning summary
|
|
493
|
+
app.get('/api/learnings/latest', requireAuth, async (req, res) => {
|
|
494
|
+
try {
|
|
495
|
+
const { generateLearningSummary, loadPreviousRatings, TOOL_RATINGS_PATH } = require('./lib/post-session');
|
|
496
|
+
const { getPreDispatchContext } = require('./lib/pre-dispatch');
|
|
497
|
+
const { validateHypotheses } = require('./lib/hypothesis-validator');
|
|
498
|
+
const fs = require('fs');
|
|
499
|
+
|
|
500
|
+
// Load current and previous ratings
|
|
501
|
+
let currentRatings = {};
|
|
502
|
+
if (fs.existsSync(TOOL_RATINGS_PATH)) {
|
|
503
|
+
currentRatings = JSON.parse(fs.readFileSync(TOOL_RATINGS_PATH, 'utf8'));
|
|
504
|
+
}
|
|
505
|
+
const previousRatings = loadPreviousRatings();
|
|
506
|
+
|
|
507
|
+
// Get hypothesis validation status
|
|
508
|
+
const hypothesisResults = validateHypotheses();
|
|
509
|
+
const hypothesisValidation = {
|
|
510
|
+
promoted: hypothesisResults.filter(r => r.decision === 'promote').map(r => r.hypothesis),
|
|
511
|
+
rejected: hypothesisResults.filter(r => r.decision === 'reject').map(r => r.hypothesis),
|
|
512
|
+
continue: hypothesisResults.filter(r => r.decision === 'continue').map(r => r.hypothesis),
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
// Get current guidance
|
|
516
|
+
const ctx = await getPreDispatchContext();
|
|
517
|
+
const guidance = ctx.toolGuidance || [];
|
|
518
|
+
|
|
519
|
+
// Generate summary
|
|
520
|
+
const summary = generateLearningSummary(currentRatings, previousRatings, hypothesisValidation, guidance);
|
|
521
|
+
|
|
522
|
+
res.json(summary);
|
|
523
|
+
} catch (err) {
|
|
524
|
+
console.error('[learnings/latest] Failed:', err.message);
|
|
525
|
+
res.status(500).json({ error: 'Failed to generate learning summary', detail: err.message });
|
|
526
|
+
}
|
|
527
|
+
});
|
|
528
|
+
|
|
309
529
|
// List terminals
|
|
310
|
-
app.get('/api/terminals', (req, res) => {
|
|
530
|
+
app.get('/api/terminals', requireAuth, (req, res) => {
|
|
311
531
|
const list = [];
|
|
312
532
|
for (const [, t] of terminals) {
|
|
313
533
|
const recentLines = t.lineBuffer.last(50);
|
|
@@ -332,12 +552,28 @@ app.get('/api/terminals', (req, res) => {
|
|
|
332
552
|
});
|
|
333
553
|
|
|
334
554
|
// Spawn terminal
|
|
335
|
-
app.post('/api/terminals', (req, res) => {
|
|
555
|
+
app.post('/api/terminals', requireAuth, (req, res) => {
|
|
336
556
|
try {
|
|
557
|
+
const { tier, terminalsMax } = req.ninjaUser;
|
|
558
|
+
|
|
559
|
+
// Check terminal limit
|
|
560
|
+
if (activeSession && activeSession.terminalIds.length >= terminalsMax) {
|
|
561
|
+
return res.status(403).json({
|
|
562
|
+
error: 'Terminal limit reached',
|
|
563
|
+
detail: `Your ${tier} tier allows ${terminalsMax} terminal(s)`,
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
|
|
337
567
|
const label = req.body?.label;
|
|
338
568
|
const scope = req.body?.scope || [];
|
|
339
569
|
const cwd = req.body?.cwd || null;
|
|
340
|
-
const terminal = spawnTerminal(label, scope, cwd);
|
|
570
|
+
const terminal = spawnTerminal(label, scope, cwd, tier);
|
|
571
|
+
|
|
572
|
+
// Track in session
|
|
573
|
+
if (activeSession) {
|
|
574
|
+
activeSession.terminalIds.push(terminal.id);
|
|
575
|
+
}
|
|
576
|
+
|
|
341
577
|
res.json({ id: terminal.id, label: terminal.label, status: terminal.status, scope: terminal.scope, cwd: terminal.cwd });
|
|
342
578
|
} catch (err) {
|
|
343
579
|
res.status(500).json({ error: 'Failed to spawn terminal', detail: err.message });
|
|
@@ -345,7 +581,7 @@ app.post('/api/terminals', (req, res) => {
|
|
|
345
581
|
});
|
|
346
582
|
|
|
347
583
|
// Delete terminal
|
|
348
|
-
app.delete('/api/terminals/:id', (req, res) => {
|
|
584
|
+
app.delete('/api/terminals/:id', requireAuth, (req, res) => {
|
|
349
585
|
const id = parseInt(req.params.id, 10);
|
|
350
586
|
const terminal = terminals.get(id);
|
|
351
587
|
if (!terminal) return res.status(404).json({ error: 'Not found' });
|
|
@@ -353,15 +589,22 @@ app.delete('/api/terminals/:id', (req, res) => {
|
|
|
353
589
|
terminal.pty.kill();
|
|
354
590
|
for (const ws of terminal.clients) ws.close();
|
|
355
591
|
terminals.delete(id);
|
|
592
|
+
|
|
593
|
+
// Remove from active session
|
|
594
|
+
if (activeSession) {
|
|
595
|
+
activeSession.terminalIds = activeSession.terminalIds.filter(tid => tid !== id);
|
|
596
|
+
}
|
|
597
|
+
|
|
356
598
|
res.json({ ok: true });
|
|
357
599
|
});
|
|
358
600
|
|
|
359
601
|
// Restart terminal
|
|
360
|
-
app.post('/api/terminals/:id/restart', (req, res) => {
|
|
602
|
+
app.post('/api/terminals/:id/restart', requireAuth, (req, res) => {
|
|
361
603
|
const id = parseInt(req.params.id, 10);
|
|
362
604
|
const terminal = terminals.get(id);
|
|
363
605
|
if (!terminal) return res.status(404).json({ error: 'Not found' });
|
|
364
606
|
|
|
607
|
+
const { tier } = req.ninjaUser;
|
|
365
608
|
const label = terminal.label;
|
|
366
609
|
const scope = terminal.scope;
|
|
367
610
|
const termCwd = terminal.cwd;
|
|
@@ -369,12 +612,19 @@ app.post('/api/terminals/:id/restart', (req, res) => {
|
|
|
369
612
|
for (const ws of terminal.clients) ws.close();
|
|
370
613
|
terminals.delete(id);
|
|
371
614
|
|
|
372
|
-
const newTerminal = spawnTerminal(label, scope, termCwd);
|
|
615
|
+
const newTerminal = spawnTerminal(label, scope, termCwd, tier);
|
|
616
|
+
|
|
617
|
+
// Update session tracking
|
|
618
|
+
if (activeSession) {
|
|
619
|
+
activeSession.terminalIds = activeSession.terminalIds.filter(tid => tid !== id);
|
|
620
|
+
activeSession.terminalIds.push(newTerminal.id);
|
|
621
|
+
}
|
|
622
|
+
|
|
373
623
|
res.json({ id: newTerminal.id, label: newTerminal.label, status: newTerminal.status });
|
|
374
624
|
});
|
|
375
625
|
|
|
376
626
|
// Send input
|
|
377
|
-
app.post('/api/terminals/:id/input', (req, res) => {
|
|
627
|
+
app.post('/api/terminals/:id/input', requireAuth, async (req, res) => {
|
|
378
628
|
const id = parseInt(req.params.id, 10);
|
|
379
629
|
const terminal = terminals.get(id);
|
|
380
630
|
if (!terminal) return res.status(404).json({ error: 'Not found' });
|
|
@@ -382,12 +632,33 @@ app.post('/api/terminals/:id/input', (req, res) => {
|
|
|
382
632
|
const text = req.body?.text;
|
|
383
633
|
if (!text) return res.status(400).json({ error: 'text required' });
|
|
384
634
|
|
|
385
|
-
|
|
386
|
-
|
|
635
|
+
let finalText = text;
|
|
636
|
+
let guidanceInjected = false;
|
|
637
|
+
|
|
638
|
+
// Inject guidance from prior sessions if enabled
|
|
639
|
+
if (INJECT_GUIDANCE) {
|
|
640
|
+
try {
|
|
641
|
+
const ctx = await getPreDispatchContext();
|
|
642
|
+
const hasGuidance = ctx.toolGuidance.length > 0 || ctx.playbookInsights.length > 0;
|
|
643
|
+
|
|
644
|
+
if (hasGuidance) {
|
|
645
|
+
const guidanceBlock = formatContextForInjection(ctx);
|
|
646
|
+
finalText = `${guidanceBlock}\n\n${text}`;
|
|
647
|
+
guidanceInjected = true;
|
|
648
|
+
console.log(`[guidance] Injected ${ctx.toolGuidance.length} tool hints + ${ctx.playbookInsights.length} playbook insights into T${terminal.id}`);
|
|
649
|
+
}
|
|
650
|
+
} catch (err) {
|
|
651
|
+
console.error(`[guidance] Failed to load pre-dispatch context: ${err.message}`);
|
|
652
|
+
// Continue without guidance — don't block the input
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
terminal.pty.write(finalText);
|
|
657
|
+
res.json({ ok: true, guidanceInjected });
|
|
387
658
|
});
|
|
388
659
|
|
|
389
660
|
// Set label
|
|
390
|
-
app.post('/api/terminals/:id/label', (req, res) => {
|
|
661
|
+
app.post('/api/terminals/:id/label', requireAuth, (req, res) => {
|
|
391
662
|
const id = parseInt(req.params.id, 10);
|
|
392
663
|
const terminal = terminals.get(id);
|
|
393
664
|
if (!terminal) return res.status(404).json({ error: 'Not found' });
|
|
@@ -397,7 +668,7 @@ app.post('/api/terminals/:id/label', (req, res) => {
|
|
|
397
668
|
});
|
|
398
669
|
|
|
399
670
|
// Get status
|
|
400
|
-
app.get('/api/terminals/:id/status', (req, res) => {
|
|
671
|
+
app.get('/api/terminals/:id/status', requireAuth, (req, res) => {
|
|
401
672
|
const id = parseInt(req.params.id, 10);
|
|
402
673
|
const terminal = terminals.get(id);
|
|
403
674
|
if (!terminal) return res.status(404).json({ error: 'Not found' });
|
|
@@ -416,7 +687,7 @@ app.get('/api/terminals/:id/status', (req, res) => {
|
|
|
416
687
|
});
|
|
417
688
|
|
|
418
689
|
// Paginated output
|
|
419
|
-
app.get('/api/terminals/:id/output', (req, res) => {
|
|
690
|
+
app.get('/api/terminals/:id/output', requireAuth, (req, res) => {
|
|
420
691
|
const id = parseInt(req.params.id, 10);
|
|
421
692
|
const terminal = terminals.get(id);
|
|
422
693
|
if (!terminal) return res.status(404).json({ error: 'Not found' });
|
|
@@ -428,7 +699,7 @@ app.get('/api/terminals/:id/output', (req, res) => {
|
|
|
428
699
|
});
|
|
429
700
|
|
|
430
701
|
// Structured log
|
|
431
|
-
app.get('/api/terminals/:id/log', (req, res) => {
|
|
702
|
+
app.get('/api/terminals/:id/log', requireAuth, (req, res) => {
|
|
432
703
|
const id = parseInt(req.params.id, 10);
|
|
433
704
|
const terminal = terminals.get(id);
|
|
434
705
|
if (!terminal) return res.status(404).json({ error: 'Not found' });
|
|
@@ -437,7 +708,7 @@ app.get('/api/terminals/:id/log', (req, res) => {
|
|
|
437
708
|
});
|
|
438
709
|
|
|
439
710
|
// Graceful kill
|
|
440
|
-
app.post('/api/terminals/:id/kill', async (req, res) => {
|
|
711
|
+
app.post('/api/terminals/:id/kill', requireAuth, async (req, res) => {
|
|
441
712
|
const id = parseInt(req.params.id, 10);
|
|
442
713
|
const terminal = terminals.get(id);
|
|
443
714
|
if (!terminal) return res.status(404).json({ error: 'Not found' });
|
|
@@ -459,7 +730,7 @@ app.post('/api/terminals/:id/kill', async (req, res) => {
|
|
|
459
730
|
res.json({ ok: true });
|
|
460
731
|
});
|
|
461
732
|
|
|
462
|
-
// Permission evaluation hook endpoint
|
|
733
|
+
// Permission evaluation hook endpoint (no auth — called by Claude Code hooks)
|
|
463
734
|
app.post('/api/terminals/:id/evaluate', createEvaluateMiddleware(getTerminalRules));
|
|
464
735
|
|
|
465
736
|
// Worker stopped hook endpoint
|
|
@@ -495,7 +766,7 @@ app.post('/api/terminals/:id/compacted', (req, res) => {
|
|
|
495
766
|
});
|
|
496
767
|
|
|
497
768
|
// Assign task to terminal
|
|
498
|
-
app.post('/api/terminals/:id/task', (req, res) => {
|
|
769
|
+
app.post('/api/terminals/:id/task', requireAuth, (req, res) => {
|
|
499
770
|
const id = parseInt(req.params.id, 10);
|
|
500
771
|
const terminal = terminals.get(id);
|
|
501
772
|
if (!terminal) return res.status(404).json({ error: 'Not found' });
|
|
@@ -537,7 +808,7 @@ app.get('/api/events', (req, res) => {
|
|
|
537
808
|
|
|
538
809
|
// ── Task DAG ────────────────────────────────────────────────
|
|
539
810
|
|
|
540
|
-
app.get('/api/tasks', (_req, res) => {
|
|
811
|
+
app.get('/api/tasks', requireAuth, (_req, res) => {
|
|
541
812
|
try {
|
|
542
813
|
res.json(taskDag.toJSON());
|
|
543
814
|
} catch (err) {
|
|
@@ -545,7 +816,7 @@ app.get('/api/tasks', (_req, res) => {
|
|
|
545
816
|
}
|
|
546
817
|
});
|
|
547
818
|
|
|
548
|
-
app.post('/api/tasks', (req, res) => {
|
|
819
|
+
app.post('/api/tasks', requireAuth, (req, res) => {
|
|
549
820
|
try {
|
|
550
821
|
const { id, name, description, dependencies, scope } = req.body || {};
|
|
551
822
|
if (!id || !name) return res.status(400).json({ error: 'id and name required' });
|
|
@@ -558,7 +829,7 @@ app.post('/api/tasks', (req, res) => {
|
|
|
558
829
|
}
|
|
559
830
|
});
|
|
560
831
|
|
|
561
|
-
app.delete('/api/tasks/:id', (req, res) => {
|
|
832
|
+
app.delete('/api/tasks/:id', requireAuth, (req, res) => {
|
|
562
833
|
try {
|
|
563
834
|
taskDag.removeTask(req.params.id);
|
|
564
835
|
sse.broadcast('task_removed', { id: req.params.id, ts: new Date().toISOString() });
|
|
@@ -570,7 +841,7 @@ app.delete('/api/tasks/:id', (req, res) => {
|
|
|
570
841
|
|
|
571
842
|
// ── Self-Improvement Metrics API ────────────────────────────
|
|
572
843
|
|
|
573
|
-
app.get('/api/metrics/tools', async (_req, res) => {
|
|
844
|
+
app.get('/api/metrics/tools', requireAuth, async (_req, res) => {
|
|
574
845
|
try {
|
|
575
846
|
const ratings = await rateTools();
|
|
576
847
|
const result = {};
|
|
@@ -581,7 +852,7 @@ app.get('/api/metrics/tools', async (_req, res) => {
|
|
|
581
852
|
}
|
|
582
853
|
});
|
|
583
854
|
|
|
584
|
-
app.get('/api/metrics/sessions', (req, res) => {
|
|
855
|
+
app.get('/api/metrics/sessions', requireAuth, (req, res) => {
|
|
585
856
|
try {
|
|
586
857
|
const summariesPath = path.join(__dirname, 'orchestrator', 'metrics', 'summaries.ndjson');
|
|
587
858
|
const fs = require('fs');
|
|
@@ -595,7 +866,7 @@ app.get('/api/metrics/sessions', (req, res) => {
|
|
|
595
866
|
}
|
|
596
867
|
});
|
|
597
868
|
|
|
598
|
-
app.get('/api/metrics/friction', (_req, res) => {
|
|
869
|
+
app.get('/api/metrics/friction', requireAuth, (_req, res) => {
|
|
599
870
|
try {
|
|
600
871
|
const summariesPath = path.join(__dirname, 'orchestrator', 'metrics', 'summaries.ndjson');
|
|
601
872
|
const fs = require('fs');
|
|
@@ -630,7 +901,7 @@ app.get('/api/metrics/friction', (_req, res) => {
|
|
|
630
901
|
}
|
|
631
902
|
});
|
|
632
903
|
|
|
633
|
-
app.post('/api/orchestrator/evolve', (req, res) => {
|
|
904
|
+
app.post('/api/orchestrator/evolve', requireAuth, (req, res) => {
|
|
634
905
|
try {
|
|
635
906
|
const { action, target, content, reason, evidence } = req.body || {};
|
|
636
907
|
if (!action || !target) return res.status(400).json({ error: 'action and target required' });
|
|
@@ -665,7 +936,7 @@ app.post('/api/orchestrator/evolve', (req, res) => {
|
|
|
665
936
|
}
|
|
666
937
|
});
|
|
667
938
|
|
|
668
|
-
app.get('/api/metrics/playbooks', (_req, res) => {
|
|
939
|
+
app.get('/api/metrics/playbooks', requireAuth, (_req, res) => {
|
|
669
940
|
try {
|
|
670
941
|
const playbooksPath = path.join(__dirname, 'orchestrator', 'playbooks.md');
|
|
671
942
|
const summariesPath = path.join(__dirname, 'orchestrator', 'metrics', 'summaries.ndjson');
|
|
@@ -678,18 +949,72 @@ app.get('/api/metrics/playbooks', (_req, res) => {
|
|
|
678
949
|
}
|
|
679
950
|
});
|
|
680
951
|
|
|
952
|
+
// ── Session Invalidation Handler ────────────────────────────
|
|
953
|
+
|
|
954
|
+
function handleSessionInvalidation(token) {
|
|
955
|
+
if (!activeSession || activeSession.token !== token) return;
|
|
956
|
+
|
|
957
|
+
console.log(`[auth] Session invalidated, killing ${activeSession.terminalIds.length} terminals`);
|
|
958
|
+
|
|
959
|
+
// Kill all terminals for this session
|
|
960
|
+
for (const id of activeSession.terminalIds) {
|
|
961
|
+
const terminal = terminals.get(id);
|
|
962
|
+
if (terminal && terminal.status !== 'exited') {
|
|
963
|
+
terminal.pty.kill('SIGTERM');
|
|
964
|
+
for (const ws of terminal.clients) ws.close();
|
|
965
|
+
terminals.delete(id);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
activeSession = null;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
// ── Auth Proxy (avoids CORS) ────────────────────────────────
|
|
973
|
+
|
|
974
|
+
const BACKEND_URL = process.env.NINJA_BACKEND_URL || 'https://emtchat-backend.onrender.com';
|
|
975
|
+
|
|
976
|
+
app.post('/api/auth/login', async (req, res) => {
|
|
977
|
+
try {
|
|
978
|
+
const fetch = require('node-fetch');
|
|
979
|
+
const resp = await fetch(`${BACKEND_URL}/api/auth/login`, {
|
|
980
|
+
method: 'POST',
|
|
981
|
+
headers: { 'Content-Type': 'application/json' },
|
|
982
|
+
body: JSON.stringify(req.body),
|
|
983
|
+
});
|
|
984
|
+
const data = await resp.json();
|
|
985
|
+
res.status(resp.status).json(data);
|
|
986
|
+
} catch (err) {
|
|
987
|
+
res.status(502).json({ error: 'Backend unreachable', detail: err.message });
|
|
988
|
+
}
|
|
989
|
+
});
|
|
990
|
+
|
|
991
|
+
app.post('/api/auth/register', async (req, res) => {
|
|
992
|
+
try {
|
|
993
|
+
const fetch = require('node-fetch');
|
|
994
|
+
const resp = await fetch(`${BACKEND_URL}/api/auth/register`, {
|
|
995
|
+
method: 'POST',
|
|
996
|
+
headers: { 'Content-Type': 'application/json' },
|
|
997
|
+
body: JSON.stringify(req.body),
|
|
998
|
+
});
|
|
999
|
+
const data = await resp.json();
|
|
1000
|
+
res.status(resp.status).json(data);
|
|
1001
|
+
} catch (err) {
|
|
1002
|
+
res.status(502).json({ error: 'Backend unreachable', detail: err.message });
|
|
1003
|
+
}
|
|
1004
|
+
});
|
|
1005
|
+
|
|
681
1006
|
// ── Start ───────────────────────────────────────────────────
|
|
682
1007
|
|
|
683
1008
|
server.listen(PORT, () => {
|
|
684
1009
|
console.log(`Ninja Terminals v2 running on http://localhost:${PORT}`);
|
|
1010
|
+
console.log(`Auth required: POST /api/session with Bearer token to start`);
|
|
685
1011
|
|
|
686
1012
|
// Start SSE heartbeat
|
|
687
1013
|
sse.startHeartbeat(15000);
|
|
688
1014
|
|
|
689
|
-
//
|
|
690
|
-
|
|
691
|
-
spawnTerminal(`T${i + 1}`, [], DEFAULT_CWD);
|
|
692
|
-
}
|
|
1015
|
+
// Start session heartbeat — re-validates tokens every 5 minutes
|
|
1016
|
+
startSessionHeartbeat(sessionCache, handleSessionInvalidation, 5 * 60 * 1000);
|
|
693
1017
|
|
|
694
|
-
|
|
1018
|
+
// NOTE: Terminals are NOT spawned on startup.
|
|
1019
|
+
// Users must POST /api/session with a valid token to create a session and spawn terminals.
|
|
695
1020
|
});
|