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/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
- const match = req.url.match(/^\/ws\/(\d+)$/);
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
- terminal.pty.write(text);
386
- res.json({ ok: true });
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
- // Spawn default terminals
690
- for (let i = 0; i < DEFAULT_TERMINALS; i++) {
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
- console.log(`Spawned ${DEFAULT_TERMINALS} terminals with Claude Code`);
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
  });