ninja-terminals 2.3.0 → 2.3.2

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
@@ -3,10 +3,13 @@ const http = require('http');
3
3
  const { WebSocketServer } = require('ws');
4
4
  const pty = require('node-pty');
5
5
  const path = require('path');
6
+ const fs = require('fs');
7
+ const os = require('os');
8
+ const multer = require('multer');
6
9
 
7
10
  // ── Lib imports ─────────────────────────────────────────────
8
11
  const { LineBuffer, RawBuffer } = require('./lib/ring-buffer');
9
- const { stripAnsi, detectStatus, extractContextPct, extractStructuredEvents } = require('./lib/status-detect');
12
+ const { stripAnsi, detectStatus, extractContextPct, extractStructuredEvents, parseTaskStatus, VALID_TASK_STATES } = require('./lib/status-detect');
10
13
  const { SSEManager } = require('./lib/sse');
11
14
  const { evaluatePermission, getDefaultRules, createEvaluateMiddleware } = require('./lib/permissions');
12
15
  const { TaskDAG } = require('./lib/task-dag');
@@ -20,9 +23,20 @@ const { isImmutable, safeWrite, safeAppend } = require('./lib/safe-file-writer')
20
23
  const { logEvolution } = require('./lib/evolution-writer');
21
24
  const { getPreDispatchContext, formatContextForInjection } = require('./lib/pre-dispatch');
22
25
  const { runPostSession } = require('./lib/post-session');
26
+ const {
27
+ SESSION_FILE,
28
+ findAvailablePort,
29
+ writeRuntimeSession,
30
+ updateRuntimeSession,
31
+ clearRuntimeSession,
32
+ openBrowserTab,
33
+ writeAuthToken,
34
+ readAuthToken,
35
+ } = require('./lib/runtime-session');
23
36
 
24
37
  // ── Config ──────────────────────────────────────────────────
25
- const PORT = process.env.PORT || 3300;
38
+ const PREFERRED_PORT = parseInt(process.env.PORT || '3300', 10);
39
+ const BIND_HOST = process.env.NINJA_BIND_HOST || '127.0.0.1';
26
40
  const DEFAULT_TERMINALS = parseInt(process.env.DEFAULT_TERMINALS || '4', 10);
27
41
  const CLAUDE_CMD = process.env.CLAUDE_CMD || 'claude --dangerously-skip-permissions';
28
42
  const SHELL = process.env.SHELL || '/bin/zsh';
@@ -30,8 +44,34 @@ const PROJECT_DIR = __dirname;
30
44
  const DEFAULT_CWD = process.env.DEFAULT_CWD || null; // Set to target project path to avoid cross-project prompts
31
45
  const INJECT_GUIDANCE = process.env.INJECT_GUIDANCE !== 'false'; // Default true, set INJECT_GUIDANCE=false to disable
32
46
 
47
+ // Fleet modes — preset terminal configurations
48
+ const FLEET_MODES = {
49
+ claude: ['claude', 'claude', 'claude', 'claude'],
50
+ teams: ['claude', 'opencode', 'codex', 'shell'],
51
+ mixed: ['claude', 'claude', 'opencode', 'shell'],
52
+ shell: ['shell', 'shell', 'shell', 'shell'],
53
+ duo: ['claude', 'opencode'],
54
+ };
55
+ const FLEET_MODE = process.env.NINJA_MODE || 'claude';
56
+
33
57
  const sleep = ms => new Promise(r => setTimeout(r, ms));
34
58
 
59
+ // Delay between text and Enter for Claude Code to recognize as submission (not paste buffer)
60
+ const SUBMIT_DELAY_MS = 180;
61
+
62
+ /**
63
+ * Submit text to a terminal with delayed Enter.
64
+ * Claude Code treats text+Enter in the same PTY write as pasted multiline input and buffers it.
65
+ * This helper writes text first, waits, then sends Enter separately.
66
+ */
67
+ async function submitToTerminal(terminal, text) {
68
+ // Strip any trailing newlines — we'll add Enter ourselves after delay
69
+ const cleanText = text.replace(/[\r\n]+$/, '');
70
+ terminal.pty.write(cleanText);
71
+ await sleep(SUBMIT_DELAY_MS);
72
+ terminal.pty.write('\r');
73
+ }
74
+
35
75
  // ── Express + WS ────────────────────────────────────────────
36
76
  const app = express();
37
77
  const server = http.createServer(app);
@@ -60,6 +100,19 @@ const requireAuth = (req, res, next) => {
60
100
  return authMiddleware(req, res, next);
61
101
  };
62
102
 
103
+ // Loopback check for local-only endpoints (e.g., auth bootstrap)
104
+ function isLoopbackRequest(req) {
105
+ const remote = req.socket?.remoteAddress || req.connection?.remoteAddress || '';
106
+ const loopbackAddrs = ['127.0.0.1', '::1', '::ffff:127.0.0.1'];
107
+ if (!loopbackAddrs.includes(remote)) return false;
108
+
109
+ // Also check Host header matches localhost
110
+ const host = req.get('host') || '';
111
+ const hostWithoutPort = host.split(':')[0];
112
+ const validHosts = ['localhost', '127.0.0.1', '[::1]', '::1'];
113
+ return validHosts.includes(hostWithoutPort);
114
+ }
115
+
63
116
  const sse = new SSEManager();
64
117
  const taskDag = new TaskDAG();
65
118
  const retryBudget = new RetryBudget();
@@ -107,18 +160,32 @@ function getTerminalRules(terminalId) {
107
160
 
108
161
  // ── Terminal Spawning ───────────────────────────────────────
109
162
 
110
- function spawnTerminal(label, scope = [], cwd = null, tier = 'pro') {
163
+ const AGENT_COMMANDS = {
164
+ claude: process.env.CLAUDE_CMD || 'claude --dangerously-skip-permissions',
165
+ opencode: 'opencode',
166
+ codex: 'codex',
167
+ shell: null, // No command — just shell
168
+ };
169
+
170
+ const VALID_AGENT_TYPES = Object.keys(AGENT_COMMANDS);
171
+
172
+ function spawnTerminal(label, scope = [], cwd = null, tier = 'pro', agentType = 'claude') {
111
173
  const id = nextId++;
112
174
  const cols = 120;
113
175
  const rows = 30;
114
176
 
115
177
  // Resolve working directory — custom cwd or default to PROJECT_DIR
116
- const workDir = cwd || PROJECT_DIR;
117
- const settingsDir = cwd || PROJECT_DIR;
178
+ // Validate cwd to prevent broken paths like "\" from corrupting terminal startup
179
+ let workDir = cwd || PROJECT_DIR;
180
+ if (!workDir || workDir.length < 2 || !path.isAbsolute(workDir)) {
181
+ console.warn(`[spawn] Invalid cwd "${workDir}", falling back to PROJECT_DIR`);
182
+ workDir = PROJECT_DIR;
183
+ }
184
+ const settingsDir = workDir;
118
185
 
119
186
  // Write worker settings to the TARGET project (not ninja-terminal)
120
187
  try {
121
- writeWorkerSettings(id, settingsDir, scope, { port: PORT, tier });
188
+ writeWorkerSettings(id, settingsDir, scope, { port: server.address()?.port || PREFERRED_PORT, tier });
122
189
  } catch (e) {
123
190
  console.error(`Failed to write worker settings for terminal ${id}:`, e.message);
124
191
  }
@@ -146,14 +213,20 @@ function spawnTerminal(label, scope = [], cwd = null, tier = 'pro') {
146
213
  },
147
214
  });
148
215
 
149
- // After shell starts, cd to work dir and launch claude
216
+ // After shell starts, cd to work dir and launch agent (if not shell)
217
+ const agentCmd = AGENT_COMMANDS[agentType] || null;
150
218
  setTimeout(() => {
151
- ptyProcess.write(`cd "${workDir}" && ${CLAUDE_CMD}\r`);
219
+ if (agentCmd) {
220
+ ptyProcess.write(`cd "${workDir}" && ${agentCmd}\r`);
221
+ } else {
222
+ ptyProcess.write(`cd "${workDir}"\r`);
223
+ }
152
224
  }, 500);
153
225
 
154
226
  const terminal = {
155
227
  id,
156
228
  label: label || `T${id}`,
229
+ agentType: agentType || 'claude',
157
230
  pty: ptyProcess,
158
231
  clients: new Set(),
159
232
  status: 'starting',
@@ -172,6 +245,14 @@ function spawnTerminal(label, scope = [], cwd = null, tier = 'pro') {
172
245
  previousFiles: [],
173
246
  lastTaskCompletedAt: null,
174
247
  circuitBreaker: new CircuitBreaker(id),
248
+ // Semantic task status (separate from process status)
249
+ taskStatus: {
250
+ state: 'pending',
251
+ marker: null,
252
+ message: null,
253
+ updatedAt: new Date().toISOString(),
254
+ source: 'init',
255
+ },
175
256
  };
176
257
 
177
258
  // ── PTY data handler ──────────────────────────────────────
@@ -194,6 +275,34 @@ function spawnTerminal(label, scope = [], cwd = null, tier = 'pro') {
194
275
  sse.broadcast(evt.type, evt);
195
276
  }
196
277
 
278
+ // Parse task status from recent buffered lines (not just current chunk)
279
+ // This handles STATUS markers split across PTY chunks
280
+ const recentLines = terminal.lineBuffer.last(20);
281
+ const taskStatusResult = parseTaskStatus(recentLines);
282
+ if (taskStatusResult) {
283
+ const prevState = terminal.taskStatus.state;
284
+ // Only update if state changed (avoid redundant updates from same marker)
285
+ if (prevState !== taskStatusResult.state) {
286
+ terminal.taskStatus = {
287
+ state: taskStatusResult.state,
288
+ marker: taskStatusResult.marker,
289
+ message: taskStatusResult.message,
290
+ updatedAt: new Date().toISOString(),
291
+ source: 'output-parser',
292
+ };
293
+
294
+ // Broadcast task status change
295
+ sse.broadcast('task_status_change', {
296
+ terminal: terminal.label,
297
+ id: terminal.id,
298
+ processStatus: terminal.status,
299
+ taskStatus: terminal.taskStatus,
300
+ ts: terminal.taskStatus.updatedAt,
301
+ });
302
+ console.log(`[task-status] T${terminal.id} (${terminal.label}): ${prevState} -> ${taskStatusResult.state}`);
303
+ }
304
+ }
305
+
197
306
  // Broadcast raw to WebSocket clients
198
307
  for (const ws of terminal.clients) {
199
308
  if (ws.readyState === 1) ws.send(data);
@@ -359,6 +468,31 @@ server.on('upgrade', async (req, socket, head) => {
359
468
 
360
469
  // ── API Routes ──────────────────────────────────────────────
361
470
 
471
+ // ── File Upload ─────────────────────────────────────────────
472
+ const UPLOAD_DIR = path.join(os.homedir(), '.ninja', 'uploads');
473
+ fs.mkdirSync(UPLOAD_DIR, { recursive: true, mode: 0o700 });
474
+
475
+ const upload = multer({
476
+ storage: multer.diskStorage({
477
+ destination: UPLOAD_DIR,
478
+ filename: (_req, file, cb) => {
479
+ const ext = path.extname(file.originalname) || '';
480
+ const base = path.basename(file.originalname, ext).replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 50);
481
+ const name = `${base}-${Date.now()}${ext}`;
482
+ cb(null, name);
483
+ },
484
+ }),
485
+ limits: { fileSize: 50 * 1024 * 1024 }, // 50MB max
486
+ });
487
+
488
+ app.post('/api/upload', requireAuth, upload.single('file'), (req, res) => {
489
+ if (!req.file) {
490
+ return res.status(400).json({ error: 'No file uploaded' });
491
+ }
492
+ const filePath = req.file.path;
493
+ res.json({ path: filePath, filename: req.file.filename, size: req.file.size });
494
+ });
495
+
362
496
  // Health
363
497
  app.get('/health', (_req, res) => {
364
498
  res.json({
@@ -387,6 +521,18 @@ app.post('/api/session', requireAuth, (req, res) => {
387
521
 
388
522
  console.log(`[session] Returning existing session: tier=${tier}, terminals=${existingTerminals.length}`);
389
523
 
524
+ // Repair session.json authToken if missing (e.g., after orphan recovery)
525
+ updateRuntimeSession({
526
+ authToken: token,
527
+ tier,
528
+ terminalsMax,
529
+ features,
530
+ activeSessionCreatedAt: activeSession.createdAt
531
+ ? new Date(activeSession.createdAt).toISOString()
532
+ : undefined,
533
+ });
534
+ writeAuthToken(token);
535
+
390
536
  return res.json({
391
537
  tier,
392
538
  terminalsMax,
@@ -417,6 +563,15 @@ app.post('/api/session', requireAuth, (req, res) => {
417
563
  createdAt: Date.now(),
418
564
  };
419
565
 
566
+ updateRuntimeSession({
567
+ authToken: token,
568
+ tier,
569
+ terminalsMax,
570
+ features,
571
+ activeSessionCreatedAt: new Date(activeSession.createdAt).toISOString(),
572
+ });
573
+ writeAuthToken(token);
574
+
420
575
  console.log(`[session] Created new session: tier=${tier}, terminalsMax=${terminalsMax}`);
421
576
 
422
577
  // Return empty terminals - user can add via + button
@@ -563,7 +718,10 @@ app.get('/api/terminals', requireAuth, (req, res) => {
563
718
  const entry = {
564
719
  id: t.id,
565
720
  label: t.label,
721
+ agentType: t.agentType || 'claude',
566
722
  status: t.status,
723
+ taskStatus: t.taskStatus.state,
724
+ taskStatusMessage: t.taskStatus.message,
567
725
  elapsed: getElapsed(t),
568
726
  contextPct: extractContextPct(recentLines),
569
727
  cols: t.cols,
@@ -588,14 +746,15 @@ app.post('/api/terminals', requireAuth, (req, res) => {
588
746
  const label = req.body?.label;
589
747
  const scope = req.body?.scope || [];
590
748
  const cwd = req.body?.cwd || null;
591
- const terminal = spawnTerminal(label, scope, cwd, tier);
749
+ const agentType = VALID_AGENT_TYPES.includes(req.body?.agentType) ? req.body.agentType : 'claude';
750
+ const terminal = spawnTerminal(label, scope, cwd, tier, agentType);
592
751
 
593
752
  // Track in session
594
753
  if (activeSession) {
595
754
  activeSession.terminalIds.push(terminal.id);
596
755
  }
597
756
 
598
- res.json({ id: terminal.id, label: terminal.label, status: terminal.status, scope: terminal.scope, cwd: terminal.cwd });
757
+ res.json({ id: terminal.id, label: terminal.label, agentType: terminal.agentType, status: terminal.status, scope: terminal.scope, cwd: terminal.cwd });
599
758
  } catch (err) {
600
759
  res.status(500).json({ error: 'Failed to spawn terminal', detail: err.message });
601
760
  }
@@ -634,11 +793,13 @@ app.post('/api/terminals/:id/restart', requireAuth, (req, res) => {
634
793
  const label = terminal.label;
635
794
  const scope = terminal.scope;
636
795
  const termCwd = terminal.cwd;
796
+ // Allow changing agentType on restart, default to current
797
+ const agentType = VALID_AGENT_TYPES.includes(req.body?.agentType) ? req.body.agentType : (terminal.agentType || 'claude');
637
798
  terminal.pty.kill();
638
799
  for (const ws of terminal.clients) ws.close();
639
800
  terminals.delete(id);
640
801
 
641
- const newTerminal = spawnTerminal(label, scope, termCwd, tier);
802
+ const newTerminal = spawnTerminal(label, scope, termCwd, tier, agentType);
642
803
 
643
804
  // Update session tracking
644
805
  if (activeSession) {
@@ -646,7 +807,7 @@ app.post('/api/terminals/:id/restart', requireAuth, (req, res) => {
646
807
  activeSession.terminalIds.push(newTerminal.id);
647
808
  }
648
809
 
649
- res.json({ id: newTerminal.id, label: newTerminal.label, status: newTerminal.status });
810
+ res.json({ id: newTerminal.id, label: newTerminal.label, agentType: newTerminal.agentType, status: newTerminal.status });
650
811
  });
651
812
 
652
813
  // Send input
@@ -658,11 +819,33 @@ app.post('/api/terminals/:id/input', requireAuth, async (req, res) => {
658
819
  const text = req.body?.text;
659
820
  if (!text) return res.status(400).json({ error: 'text required' });
660
821
 
822
+ // Reset task status on new dispatch (clear previous done/error state)
823
+ const prevTaskState = terminal.taskStatus.state;
824
+ terminal.taskStatus = {
825
+ state: 'running',
826
+ marker: null,
827
+ message: `Dispatched: ${text.slice(0, 80)}${text.length > 80 ? '...' : ''}`,
828
+ updatedAt: new Date().toISOString(),
829
+ source: 'dispatch',
830
+ };
831
+
832
+ // Broadcast task status reset
833
+ sse.broadcast('task_status_change', {
834
+ terminal: terminal.label,
835
+ id: terminal.id,
836
+ processStatus: terminal.status,
837
+ taskStatus: terminal.taskStatus,
838
+ ts: terminal.taskStatus.updatedAt,
839
+ });
840
+ console.log(`[task-status] T${terminal.id} (${terminal.label}): ${prevTaskState} -> running (dispatch)`);
841
+
661
842
  let finalText = text;
662
843
  let guidanceInjected = false;
663
844
 
664
- // Inject guidance from prior sessions if enabled
665
- if (INJECT_GUIDANCE) {
845
+ // Inject guidance only for Claude Code terminals (not opencode, codex, shell)
846
+ const shouldInjectGuidance = INJECT_GUIDANCE && terminal.agentType === 'claude';
847
+
848
+ if (shouldInjectGuidance) {
666
849
  try {
667
850
  const ctx = await getPreDispatchContext();
668
851
  const hasGuidance = ctx.toolGuidance.length > 0 || ctx.playbookInsights.length > 0;
@@ -679,7 +862,8 @@ app.post('/api/terminals/:id/input', requireAuth, async (req, res) => {
679
862
  }
680
863
  }
681
864
 
682
- terminal.pty.write(finalText);
865
+ // Use delayed Enter for proper Claude Code submission
866
+ await submitToTerminal(terminal, finalText);
683
867
  res.json({ ok: true, guidanceInjected });
684
868
  });
685
869
 
@@ -704,6 +888,9 @@ app.get('/api/terminals/:id/status', requireAuth, (req, res) => {
704
888
  id: terminal.id,
705
889
  label: terminal.label,
706
890
  status: terminal.status,
891
+ taskStatus: terminal.taskStatus.state,
892
+ taskStatusMessage: terminal.taskStatus.message,
893
+ taskStatusUpdatedAt: terminal.taskStatus.updatedAt,
707
894
  elapsed: getElapsed(terminal),
708
895
  contextPct: extractContextPct(recentLines),
709
896
  taskName: terminal.taskName,
@@ -712,6 +899,41 @@ app.get('/api/terminals/:id/status', requireAuth, (req, res) => {
712
899
  });
713
900
  });
714
901
 
902
+ // Get task status (semantic status separate from process status)
903
+ app.get('/api/terminals/:id/task-status', requireAuth, (req, res) => {
904
+ const id = parseInt(req.params.id, 10);
905
+ const terminal = terminals.get(id);
906
+ if (!terminal) return res.status(404).json({ error: 'Not found' });
907
+
908
+ res.json({
909
+ id: terminal.id,
910
+ label: terminal.label,
911
+ processStatus: terminal.status,
912
+ taskStatus: terminal.taskStatus.state,
913
+ marker: terminal.taskStatus.marker,
914
+ message: terminal.taskStatus.message,
915
+ updatedAt: terminal.taskStatus.updatedAt,
916
+ source: terminal.taskStatus.source,
917
+ });
918
+ });
919
+
920
+ // Get all terminals task status
921
+ app.get('/api/terminals/task-status', requireAuth, (_req, res) => {
922
+ const list = [];
923
+ for (const [, t] of terminals) {
924
+ list.push({
925
+ id: t.id,
926
+ label: t.label,
927
+ processStatus: t.status,
928
+ taskStatus: t.taskStatus.state,
929
+ marker: t.taskStatus.marker,
930
+ message: t.taskStatus.message,
931
+ updatedAt: t.taskStatus.updatedAt,
932
+ });
933
+ }
934
+ res.json(list);
935
+ });
936
+
715
937
  // Paginated output
716
938
  app.get('/api/terminals/:id/output', requireAuth, (req, res) => {
717
939
  const id = parseInt(req.params.id, 10);
@@ -792,7 +1014,7 @@ app.post('/api/terminals/:id/compacted', (req, res) => {
792
1014
  });
793
1015
 
794
1016
  // Assign task to terminal
795
- app.post('/api/terminals/:id/task', requireAuth, (req, res) => {
1017
+ app.post('/api/terminals/:id/task', requireAuth, async (req, res) => {
796
1018
  const id = parseInt(req.params.id, 10);
797
1019
  const terminal = terminals.get(id);
798
1020
  if (!terminal) return res.status(404).json({ error: 'Not found' });
@@ -804,6 +1026,26 @@ app.post('/api/terminals/:id/task', requireAuth, (req, res) => {
804
1026
  terminal.progress = null;
805
1027
  terminal.taskStartedAt = Date.now();
806
1028
 
1029
+ // Reset task status on new task assignment (clear previous DONE/BLOCKED/ERROR)
1030
+ const prevTaskState = terminal.taskStatus.state;
1031
+ terminal.taskStatus = {
1032
+ state: 'running',
1033
+ marker: null,
1034
+ message: `Task: ${name}${description ? ` — ${description.slice(0, 60)}` : ''}`,
1035
+ updatedAt: new Date().toISOString(),
1036
+ source: 'task-assign',
1037
+ };
1038
+
1039
+ // Broadcast task status reset
1040
+ sse.broadcast('task_status_change', {
1041
+ terminal: terminal.label,
1042
+ id: terminal.id,
1043
+ processStatus: terminal.status,
1044
+ taskStatus: terminal.taskStatus,
1045
+ ts: terminal.taskStatus.updatedAt,
1046
+ });
1047
+ console.log(`[task-status] T${terminal.id} (${terminal.label}): ${prevTaskState} -> running (task-assign)`);
1048
+
807
1049
  if (scope) {
808
1050
  terminal.scope = Array.isArray(scope) ? scope : [scope];
809
1051
  }
@@ -817,9 +1059,9 @@ app.post('/api/terminals/:id/task', requireAuth, (req, res) => {
817
1059
  ts: new Date().toISOString(),
818
1060
  });
819
1061
 
820
- // Send the task as input to the terminal
1062
+ // Send the task as input to the terminal with delayed Enter
821
1063
  if (description) {
822
- terminal.pty.write(`${description}\r`);
1064
+ await submitToTerminal(terminal, description);
823
1065
  }
824
1066
 
825
1067
  res.json({ ok: true, taskName: name, scope: terminal.scope });
@@ -1002,6 +1244,29 @@ function handleSessionInvalidation(token) {
1002
1244
 
1003
1245
  const BACKEND_URL = process.env.NINJA_BACKEND_URL || 'https://emtchat-backend.onrender.com';
1004
1246
 
1247
+ // Bootstrap: return saved token for local auto-login
1248
+ // SECURITY: This endpoint is local-only. Server must bind to loopback (127.0.0.1).
1249
+ // If NINJA_BIND_HOST is changed to expose the server, this endpoint refuses to serve tokens.
1250
+ app.get('/api/auth/bootstrap', (req, res) => {
1251
+ // Strict loopback check — refuse if request is not from localhost
1252
+ if (!isLoopbackRequest(req)) {
1253
+ return res.status(403).json({ error: 'Bootstrap only available from localhost' });
1254
+ }
1255
+
1256
+ // Refuse if server is bound to non-loopback (exposed to network)
1257
+ if (BIND_HOST !== '127.0.0.1' && BIND_HOST !== 'localhost' && BIND_HOST !== '::1') {
1258
+ return res.status(403).json({ error: 'Bootstrap disabled when server is network-exposed' });
1259
+ }
1260
+
1261
+ const token = readAuthToken();
1262
+ if (token) {
1263
+ res.set('Cache-Control', 'no-store');
1264
+ res.json({ token });
1265
+ } else {
1266
+ res.status(404).json({ error: 'No saved token' });
1267
+ }
1268
+ });
1269
+
1005
1270
  app.post('/api/auth/login', async (req, res) => {
1006
1271
  try {
1007
1272
  const fetch = require('node-fetch');
@@ -1034,23 +1299,61 @@ app.post('/api/auth/register', async (req, res) => {
1034
1299
 
1035
1300
  // ── Start ───────────────────────────────────────────────────
1036
1301
 
1037
- server.listen(PORT, () => {
1038
- console.log(`Ninja Terminals v2 running on http://localhost:${PORT}`);
1302
+ async function startServer() {
1303
+ const selectedPort = await findAvailablePort(PREFERRED_PORT);
1304
+ if (selectedPort !== PREFERRED_PORT) {
1305
+ console.log(`[port] ${PREFERRED_PORT} is unavailable; using ${selectedPort}`);
1306
+ }
1307
+
1308
+ server.listen(selectedPort, BIND_HOST, () => {
1309
+ const url = `http://localhost:${selectedPort}`;
1310
+ console.log(`[bind] Listening on ${BIND_HOST}:${selectedPort}`);
1311
+ const session = writeRuntimeSession({
1312
+ port: selectedPort,
1313
+ host: BIND_HOST,
1314
+ url,
1315
+ cwd: DEFAULT_CWD || process.cwd(),
1316
+ terminals: DEFAULT_TERMINALS,
1317
+ command: 'ninja-terminals',
1318
+ });
1319
+
1320
+ console.log(`Ninja Terminals v2 running on ${url}`);
1321
+ console.log(`[session] wrote ${SESSION_FILE}`);
1039
1322
 
1040
- // Start SSE heartbeat
1041
- sse.startHeartbeat(15000);
1323
+ // Open a fresh browser tab unless disabled.
1324
+ openBrowserTab(session.url);
1042
1325
 
1043
- // Start session heartbeat — re-validates tokens every 5 minutes
1044
- startSessionHeartbeat(sessionCache, handleSessionInvalidation, 5 * 60 * 1000);
1326
+ // Start SSE heartbeat
1327
+ sse.startHeartbeat(15000);
1045
1328
 
1046
- // Auto-spawn terminals based on DEFAULT_TERMINALS env var
1047
- const terminalCount = DEFAULT_TERMINALS;
1048
- const labels = ['T1', 'T2', 'T3', 'T4', 'T5', 'T6', 'T7', 'T8'];
1329
+ // Start session heartbeat re-validates tokens every 5 minutes
1330
+ startSessionHeartbeat(sessionCache, handleSessionInvalidation, 5 * 60 * 1000);
1049
1331
 
1050
- console.log(`Auto-spawning ${terminalCount} terminals...`);
1051
- for (let i = 0; i < terminalCount; i++) {
1052
- const label = labels[i] || `T${i + 1}`;
1053
- spawnTerminal(label, [], DEFAULT_CWD || process.cwd(), 'pro');
1332
+ // Auto-spawn terminals based on fleet mode
1333
+ const fleetConfig = FLEET_MODES[FLEET_MODE] || FLEET_MODES.claude;
1334
+ const terminalCount = DEFAULT_TERMINALS > 0 ? Math.min(DEFAULT_TERMINALS, fleetConfig.length) : fleetConfig.length;
1335
+ const agentLabels = { claude: 'Claude', opencode: 'OpenCode', codex: 'Codex', shell: 'Shell' };
1336
+
1337
+ console.log(`Auto-spawning ${terminalCount} terminals (mode: ${FLEET_MODE})...`);
1338
+ for (let i = 0; i < terminalCount; i++) {
1339
+ const agentType = fleetConfig[i] || 'claude';
1340
+ const label = `T${i + 1}-${agentLabels[agentType] || agentType}`;
1341
+ spawnTerminal(label, [], DEFAULT_CWD || process.cwd(), 'pro', agentType);
1342
+ }
1343
+ console.log(`All ${terminalCount} terminals ready`);
1344
+ });
1345
+ }
1346
+
1347
+ process.on('exit', () => {
1348
+ try {
1349
+ const addr = server.address();
1350
+ if (addr && addr.port) clearRuntimeSession();
1351
+ } catch {
1352
+ // ignore shutdown cleanup failures
1054
1353
  }
1055
- console.log(`All ${terminalCount} terminals ready`);
1354
+ });
1355
+
1356
+ startServer().catch((err) => {
1357
+ console.error(`Failed to start Ninja Terminals: ${err.message}`);
1358
+ process.exit(1);
1056
1359
  });