ninja-terminals 2.3.1 → 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/CLAUDE.md +81 -0
- package/ORCHESTRATOR-PROMPT.md +91 -19
- package/README.md +25 -2
- package/agent-send.js +395 -0
- package/cli.js +25 -10
- package/lib/nameGenerator.ts +101 -0
- package/lib/pre-dispatch.js +14 -4
- package/lib/runtime-session.js +337 -0
- package/lib/status-detect.js +68 -4
- package/mcp-server.js +267 -25
- package/ninja-claude-visual.js +13 -0
- package/ninja-codex-visual.js +258 -0
- package/ninja-codex.js +474 -0
- package/ninja-ensure.js +333 -0
- package/ninja-gate.js +340 -0
- package/ninja-login.js +171 -0
- package/ninja-logout.js +42 -0
- package/ninja-visual.js +125 -0
- package/ninja-whoami.js +29 -0
- package/package.json +26 -3
- package/prompts/orchestrator.md +3 -292
- package/public/app.js +197 -4
- package/public/log-viewer.html +463 -0
- package/public/style.css +64 -0
- package/server.js +335 -32
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
|
|
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
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
665
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1038
|
-
|
|
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
|
-
|
|
1041
|
-
|
|
1323
|
+
// Open a fresh browser tab unless disabled.
|
|
1324
|
+
openBrowserTab(session.url);
|
|
1042
1325
|
|
|
1043
|
-
|
|
1044
|
-
|
|
1326
|
+
// Start SSE heartbeat
|
|
1327
|
+
sse.startHeartbeat(15000);
|
|
1045
1328
|
|
|
1046
|
-
|
|
1047
|
-
|
|
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
|
-
|
|
1051
|
-
|
|
1052
|
-
const
|
|
1053
|
-
|
|
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
|
-
|
|
1354
|
+
});
|
|
1355
|
+
|
|
1356
|
+
startServer().catch((err) => {
|
|
1357
|
+
console.error(`Failed to start Ninja Terminals: ${err.message}`);
|
|
1358
|
+
process.exit(1);
|
|
1056
1359
|
});
|