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/mcp-server.js
CHANGED
|
@@ -23,14 +23,25 @@ const os = require('os');
|
|
|
23
23
|
|
|
24
24
|
// ── Lib imports ─────────────────────────────────────────────
|
|
25
25
|
const { LineBuffer, RawBuffer } = require('./lib/ring-buffer');
|
|
26
|
-
const { stripAnsi, detectStatus, extractContextPct, extractStructuredEvents } = require('./lib/status-detect');
|
|
26
|
+
const { stripAnsi, detectStatus, extractContextPct, extractStructuredEvents, parseTaskStatus } = require('./lib/status-detect');
|
|
27
27
|
const { SSEManager } = require('./lib/sse');
|
|
28
28
|
const { writeWorkerSettings } = require('./lib/settings-gen');
|
|
29
29
|
const { getPreDispatchContext, formatContextForInjection } = require('./lib/pre-dispatch');
|
|
30
30
|
const { runPostSession } = require('./lib/post-session');
|
|
31
|
+
const {
|
|
32
|
+
findAvailablePort,
|
|
33
|
+
writeRuntimeSession,
|
|
34
|
+
updateRuntimeSession,
|
|
35
|
+
readRuntimeSession,
|
|
36
|
+
healthCheckSession,
|
|
37
|
+
clearRuntimeSession,
|
|
38
|
+
readAuthToken,
|
|
39
|
+
writeAuthToken,
|
|
40
|
+
} = require('./lib/runtime-session');
|
|
31
41
|
|
|
32
42
|
// ── Config ──────────────────────────────────────────────────
|
|
33
|
-
const
|
|
43
|
+
const PREFERRED_HTTP_PORT = parseInt(process.env.HTTP_PORT || '3300', 10);
|
|
44
|
+
let HTTP_PORT = PREFERRED_HTTP_PORT;
|
|
34
45
|
const CLAUDE_CMD = process.env.CLAUDE_CMD || 'claude --dangerously-skip-permissions';
|
|
35
46
|
const SHELL = process.env.SHELL || '/bin/zsh';
|
|
36
47
|
const PROJECT_DIR = __dirname;
|
|
@@ -38,6 +49,20 @@ const INJECT_GUIDANCE = process.env.INJECT_GUIDANCE !== 'false';
|
|
|
38
49
|
|
|
39
50
|
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
|
40
51
|
|
|
52
|
+
// Delay between text and Enter for Claude Code to recognize as submission (not paste buffer)
|
|
53
|
+
const SUBMIT_DELAY_MS = 180;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Submit text to a terminal with delayed Enter.
|
|
57
|
+
* Claude Code treats text+Enter in the same PTY write as pasted multiline input and buffers it.
|
|
58
|
+
*/
|
|
59
|
+
async function submitToTerminal(terminal, text) {
|
|
60
|
+
const cleanText = text.replace(/[\r\n]+$/, '');
|
|
61
|
+
terminal.pty.write(cleanText);
|
|
62
|
+
await sleep(SUBMIT_DELAY_MS);
|
|
63
|
+
terminal.pty.write('\r');
|
|
64
|
+
}
|
|
65
|
+
|
|
41
66
|
// ── Global State ────────────────────────────────────────────
|
|
42
67
|
let nextId = 1;
|
|
43
68
|
const terminals = new Map();
|
|
@@ -76,6 +101,9 @@ function getTerminalInfo(t) {
|
|
|
76
101
|
id: t.id,
|
|
77
102
|
label: t.label,
|
|
78
103
|
status: t.status,
|
|
104
|
+
taskStatus: t.taskStatus.state,
|
|
105
|
+
taskStatusMessage: t.taskStatus.message,
|
|
106
|
+
taskStatusUpdatedAt: t.taskStatus.updatedAt,
|
|
79
107
|
elapsed: getElapsed(t),
|
|
80
108
|
contextPct: extractContextPct(recentLines),
|
|
81
109
|
taskName: t.taskName,
|
|
@@ -92,8 +120,13 @@ function spawnTerminal(label, scope = [], cwd = null, tier = 'pro') {
|
|
|
92
120
|
const cols = 120;
|
|
93
121
|
const rows = 30;
|
|
94
122
|
|
|
95
|
-
|
|
96
|
-
|
|
123
|
+
// Validate cwd to prevent broken paths like "\" from corrupting terminal startup
|
|
124
|
+
let workDir = cwd || PROJECT_DIR;
|
|
125
|
+
if (!workDir || workDir.length < 2 || !path.isAbsolute(workDir)) {
|
|
126
|
+
console.warn(`[spawn] Invalid cwd "${workDir}", falling back to PROJECT_DIR`);
|
|
127
|
+
workDir = PROJECT_DIR;
|
|
128
|
+
}
|
|
129
|
+
const settingsDir = workDir;
|
|
97
130
|
|
|
98
131
|
// Write worker settings
|
|
99
132
|
try {
|
|
@@ -148,6 +181,14 @@ function spawnTerminal(label, scope = [], cwd = null, tier = 'pro') {
|
|
|
148
181
|
progress: null,
|
|
149
182
|
scope: Array.isArray(scope) ? scope : (scope ? [scope] : []),
|
|
150
183
|
cwd: workDir,
|
|
184
|
+
// Semantic task status (separate from process status)
|
|
185
|
+
taskStatus: {
|
|
186
|
+
state: 'pending',
|
|
187
|
+
marker: null,
|
|
188
|
+
message: null,
|
|
189
|
+
updatedAt: new Date().toISOString(),
|
|
190
|
+
source: 'init',
|
|
191
|
+
},
|
|
151
192
|
};
|
|
152
193
|
|
|
153
194
|
// PTY data handler
|
|
@@ -169,6 +210,30 @@ function spawnTerminal(label, scope = [], cwd = null, tier = 'pro') {
|
|
|
169
210
|
sse.broadcast(evt.type, evt);
|
|
170
211
|
}
|
|
171
212
|
|
|
213
|
+
// Parse task status from recent buffered lines (handles split chunks)
|
|
214
|
+
const recentLines = terminal.lineBuffer.last(20);
|
|
215
|
+
const taskStatusResult = parseTaskStatus(recentLines);
|
|
216
|
+
if (taskStatusResult) {
|
|
217
|
+
const prevState = terminal.taskStatus.state;
|
|
218
|
+
if (prevState !== taskStatusResult.state) {
|
|
219
|
+
terminal.taskStatus = {
|
|
220
|
+
state: taskStatusResult.state,
|
|
221
|
+
marker: taskStatusResult.marker,
|
|
222
|
+
message: taskStatusResult.message,
|
|
223
|
+
updatedAt: new Date().toISOString(),
|
|
224
|
+
source: 'output-parser',
|
|
225
|
+
};
|
|
226
|
+
sse.broadcast('task_status_change', {
|
|
227
|
+
terminal: terminal.label,
|
|
228
|
+
id: terminal.id,
|
|
229
|
+
processStatus: terminal.status,
|
|
230
|
+
taskStatus: terminal.taskStatus,
|
|
231
|
+
ts: terminal.taskStatus.updatedAt,
|
|
232
|
+
});
|
|
233
|
+
console.error(`[task-status] T${terminal.id} (${terminal.label}): ${prevState} -> ${taskStatusResult.state}`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
172
237
|
// Broadcast to WebSocket clients
|
|
173
238
|
for (const ws of terminal.clients) {
|
|
174
239
|
if (ws.readyState === 1) ws.send(data);
|
|
@@ -288,6 +353,55 @@ app.get('/api/events', (req, res) => {
|
|
|
288
353
|
req.on('close', () => sse.removeClient(res));
|
|
289
354
|
});
|
|
290
355
|
|
|
356
|
+
app.get('/api/auth/bootstrap', (req, res) => {
|
|
357
|
+
const token = readAuthToken();
|
|
358
|
+
if (!token) {
|
|
359
|
+
res.status(404).json({ error: 'No saved token' });
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
res.set('Cache-Control', 'no-store');
|
|
363
|
+
res.json({ token });
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
app.post('/api/session', (req, res) => {
|
|
367
|
+
const authHeader = req.headers.authorization || '';
|
|
368
|
+
const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : req.body?.token || readAuthToken();
|
|
369
|
+
if (token) {
|
|
370
|
+
writeAuthToken(token);
|
|
371
|
+
updateRuntimeSession({
|
|
372
|
+
authToken: token,
|
|
373
|
+
tier: activeSession.tier,
|
|
374
|
+
terminalsMax: activeSession.terminalsMax,
|
|
375
|
+
features: activeSession.features,
|
|
376
|
+
activeSessionCreatedAt: new Date(activeSession.createdAt).toISOString(),
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const existingTerminals = [...terminals.values()].map(t => ({
|
|
381
|
+
id: t.id,
|
|
382
|
+
label: t.label,
|
|
383
|
+
status: t.status,
|
|
384
|
+
cwd: t.cwd,
|
|
385
|
+
}));
|
|
386
|
+
|
|
387
|
+
res.json({
|
|
388
|
+
tier: activeSession.tier,
|
|
389
|
+
terminalsMax: activeSession.terminalsMax,
|
|
390
|
+
features: activeSession.features,
|
|
391
|
+
terminals: existingTerminals,
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
app.get('/api/session', (_req, res) => {
|
|
396
|
+
res.json({
|
|
397
|
+
active: true,
|
|
398
|
+
tier: activeSession.tier,
|
|
399
|
+
terminalsMax: activeSession.terminalsMax,
|
|
400
|
+
features: activeSession.features,
|
|
401
|
+
terminals: [...terminals.values()].map(t => getTerminalInfo(t)),
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
|
|
291
405
|
app.get('/api/terminals', (_req, res) => {
|
|
292
406
|
const list = [];
|
|
293
407
|
for (const [, t] of terminals) {
|
|
@@ -296,6 +410,86 @@ app.get('/api/terminals', (_req, res) => {
|
|
|
296
410
|
res.json(list);
|
|
297
411
|
});
|
|
298
412
|
|
|
413
|
+
app.get('/api/terminals/task-status', (_req, res) => {
|
|
414
|
+
res.json([...terminals.values()].map(t => ({
|
|
415
|
+
id: t.id,
|
|
416
|
+
label: t.label,
|
|
417
|
+
processStatus: t.status,
|
|
418
|
+
taskStatus: t.taskStatus.state,
|
|
419
|
+
marker: t.taskStatus.marker,
|
|
420
|
+
message: t.taskStatus.message,
|
|
421
|
+
updatedAt: t.taskStatus.updatedAt,
|
|
422
|
+
})));
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
app.get('/api/terminals/:id/status', (req, res) => {
|
|
426
|
+
const terminal = terminals.get(parseInt(req.params.id, 10));
|
|
427
|
+
if (!terminal) return res.status(404).json({ error: 'Not found' });
|
|
428
|
+
res.json(getTerminalInfo(terminal));
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
app.get('/api/terminals/:id/task-status', (req, res) => {
|
|
432
|
+
const terminal = terminals.get(parseInt(req.params.id, 10));
|
|
433
|
+
if (!terminal) return res.status(404).json({ error: 'Not found' });
|
|
434
|
+
res.json({
|
|
435
|
+
id: terminal.id,
|
|
436
|
+
label: terminal.label,
|
|
437
|
+
processStatus: terminal.status,
|
|
438
|
+
taskStatus: terminal.taskStatus.state,
|
|
439
|
+
marker: terminal.taskStatus.marker,
|
|
440
|
+
message: terminal.taskStatus.message,
|
|
441
|
+
updatedAt: terminal.taskStatus.updatedAt,
|
|
442
|
+
});
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
app.get('/api/terminals/:id/output', (req, res) => {
|
|
446
|
+
const terminal = terminals.get(parseInt(req.params.id, 10));
|
|
447
|
+
if (!terminal) return res.status(404).json({ error: 'Not found' });
|
|
448
|
+
const lines = Math.max(1, parseInt(req.query.lines, 10) || 50);
|
|
449
|
+
res.json({ lines: terminal.lineBuffer.last(lines) });
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
app.post('/api/terminals/:id/input', async (req, res) => {
|
|
453
|
+
const terminal = terminals.get(parseInt(req.params.id, 10));
|
|
454
|
+
if (!terminal) return res.status(404).json({ error: 'Not found' });
|
|
455
|
+
|
|
456
|
+
const text = req.body?.text || req.body?.input || '';
|
|
457
|
+
if (!text) return res.status(400).json({ error: 'text required' });
|
|
458
|
+
|
|
459
|
+
const prevTaskState = terminal.taskStatus.state;
|
|
460
|
+
terminal.taskStatus = {
|
|
461
|
+
state: 'running',
|
|
462
|
+
marker: null,
|
|
463
|
+
message: `Dispatched: ${text.slice(0, 80)}${text.length > 80 ? '...' : ''}`,
|
|
464
|
+
updatedAt: new Date().toISOString(),
|
|
465
|
+
source: 'dispatch',
|
|
466
|
+
};
|
|
467
|
+
sse.broadcast('task_status_change', {
|
|
468
|
+
terminal: terminal.label,
|
|
469
|
+
id: terminal.id,
|
|
470
|
+
processStatus: terminal.status,
|
|
471
|
+
taskStatus: terminal.taskStatus,
|
|
472
|
+
ts: terminal.taskStatus.updatedAt,
|
|
473
|
+
});
|
|
474
|
+
console.error(`[task-status] T${terminal.id} (${terminal.label}): ${prevTaskState} -> running (http dispatch)`);
|
|
475
|
+
|
|
476
|
+
let finalText = text;
|
|
477
|
+
let guidanceInjected = false;
|
|
478
|
+
if (INJECT_GUIDANCE) {
|
|
479
|
+
try {
|
|
480
|
+
const ctx = await getPreDispatchContext();
|
|
481
|
+
const hasGuidance = ctx.toolGuidance.length > 0 || ctx.playbookInsights.length > 0;
|
|
482
|
+
if (hasGuidance) {
|
|
483
|
+
finalText = `${formatContextForInjection(ctx)}\n\n${text}`;
|
|
484
|
+
guidanceInjected = true;
|
|
485
|
+
}
|
|
486
|
+
} catch { /* continue without guidance */ }
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
await submitToTerminal(terminal, finalText);
|
|
490
|
+
res.json({ ok: true, guidanceInjected });
|
|
491
|
+
});
|
|
492
|
+
|
|
299
493
|
// ── MCP Server Setup ────────────────────────────────────────
|
|
300
494
|
|
|
301
495
|
const mcpServer = new Server(
|
|
@@ -470,6 +664,24 @@ mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
470
664
|
return { content: [{ type: 'text', text: JSON.stringify({ error: 'Terminal not found' }) }], isError: true };
|
|
471
665
|
}
|
|
472
666
|
|
|
667
|
+
// Reset task status on new dispatch
|
|
668
|
+
const prevTaskState = terminal.taskStatus.state;
|
|
669
|
+
terminal.taskStatus = {
|
|
670
|
+
state: 'running',
|
|
671
|
+
marker: null,
|
|
672
|
+
message: `Dispatched: ${args.text.slice(0, 80)}${args.text.length > 80 ? '...' : ''}`,
|
|
673
|
+
updatedAt: new Date().toISOString(),
|
|
674
|
+
source: 'dispatch',
|
|
675
|
+
};
|
|
676
|
+
sse.broadcast('task_status_change', {
|
|
677
|
+
terminal: terminal.label,
|
|
678
|
+
id: terminal.id,
|
|
679
|
+
processStatus: terminal.status,
|
|
680
|
+
taskStatus: terminal.taskStatus,
|
|
681
|
+
ts: terminal.taskStatus.updatedAt,
|
|
682
|
+
});
|
|
683
|
+
console.error(`[task-status] T${terminal.id} (${terminal.label}): ${prevTaskState} -> running (dispatch)`);
|
|
684
|
+
|
|
473
685
|
let finalText = args.text;
|
|
474
686
|
let guidanceInjected = false;
|
|
475
687
|
|
|
@@ -485,7 +697,8 @@ mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
485
697
|
} catch { /* continue without guidance */ }
|
|
486
698
|
}
|
|
487
699
|
|
|
488
|
-
|
|
700
|
+
// Use delayed Enter for proper Claude Code submission
|
|
701
|
+
await submitToTerminal(terminal, finalText);
|
|
489
702
|
return {
|
|
490
703
|
content: [{ type: 'text', text: JSON.stringify({ ok: true, guidanceInjected }) }],
|
|
491
704
|
};
|
|
@@ -613,6 +826,24 @@ mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
613
826
|
terminal.progress = null;
|
|
614
827
|
terminal.taskStartedAt = Date.now();
|
|
615
828
|
|
|
829
|
+
// Reset task status on new task assignment
|
|
830
|
+
const prevTaskState = terminal.taskStatus.state;
|
|
831
|
+
terminal.taskStatus = {
|
|
832
|
+
state: 'running',
|
|
833
|
+
marker: null,
|
|
834
|
+
message: `Task: ${args.name}${args.description ? ` — ${args.description.slice(0, 60)}` : ''}`,
|
|
835
|
+
updatedAt: new Date().toISOString(),
|
|
836
|
+
source: 'task-assign',
|
|
837
|
+
};
|
|
838
|
+
sse.broadcast('task_status_change', {
|
|
839
|
+
terminal: terminal.label,
|
|
840
|
+
id: terminal.id,
|
|
841
|
+
processStatus: terminal.status,
|
|
842
|
+
taskStatus: terminal.taskStatus,
|
|
843
|
+
ts: terminal.taskStatus.updatedAt,
|
|
844
|
+
});
|
|
845
|
+
console.error(`[task-status] T${terminal.id} (${terminal.label}): ${prevTaskState} -> running (task-assign)`);
|
|
846
|
+
|
|
616
847
|
if (args.scope) {
|
|
617
848
|
terminal.scope = Array.isArray(args.scope) ? args.scope : [args.scope];
|
|
618
849
|
}
|
|
@@ -626,9 +857,9 @@ mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
626
857
|
ts: new Date().toISOString(),
|
|
627
858
|
});
|
|
628
859
|
|
|
629
|
-
// Send task description as input
|
|
860
|
+
// Send task description as input with delayed Enter
|
|
630
861
|
if (args.description) {
|
|
631
|
-
terminal
|
|
862
|
+
await submitToTerminal(terminal, args.description);
|
|
632
863
|
}
|
|
633
864
|
|
|
634
865
|
return {
|
|
@@ -694,36 +925,38 @@ mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
694
925
|
|
|
695
926
|
// ── Start Servers ───────────────────────────────────────────
|
|
696
927
|
|
|
697
|
-
// Check if port is in use
|
|
698
|
-
function isPortInUse(port) {
|
|
699
|
-
return new Promise((resolve) => {
|
|
700
|
-
const net = require('net');
|
|
701
|
-
const server = net.createServer();
|
|
702
|
-
server.once('error', () => resolve(true));
|
|
703
|
-
server.once('listening', () => {
|
|
704
|
-
server.close();
|
|
705
|
-
resolve(false);
|
|
706
|
-
});
|
|
707
|
-
server.listen(port, '127.0.0.1');
|
|
708
|
-
});
|
|
709
|
-
}
|
|
710
|
-
|
|
711
928
|
// Proxy mode flag - when true, MCP tools should call existing server via HTTP
|
|
712
929
|
let proxyMode = false;
|
|
930
|
+
let ownsRuntimeSession = false;
|
|
713
931
|
|
|
714
932
|
async function main() {
|
|
715
|
-
|
|
716
|
-
const
|
|
933
|
+
const runtimeSession = readRuntimeSession();
|
|
934
|
+
const runtimeHealth = await healthCheckSession(runtimeSession);
|
|
717
935
|
|
|
718
|
-
if (
|
|
936
|
+
if (runtimeHealth.ok) {
|
|
719
937
|
// Proxy mode: server.js is already running, just run MCP on stdio
|
|
720
938
|
proxyMode = true;
|
|
939
|
+
HTTP_PORT = runtimeSession.port;
|
|
721
940
|
console.error(`Ninja Terminals server already running on port ${HTTP_PORT}`);
|
|
722
941
|
console.error('MCP server starting in proxy mode (will use existing server)');
|
|
723
942
|
} else {
|
|
943
|
+
HTTP_PORT = await findAvailablePort(PREFERRED_HTTP_PORT);
|
|
944
|
+
if (HTTP_PORT !== PREFERRED_HTTP_PORT) {
|
|
945
|
+
console.error(`Ninja Terminals preferred HTTP port ${PREFERRED_HTTP_PORT} unavailable; using ${HTTP_PORT}`);
|
|
946
|
+
}
|
|
947
|
+
|
|
724
948
|
// Standalone mode: start our own HTTP server
|
|
725
949
|
httpServer.listen(HTTP_PORT, () => {
|
|
726
|
-
|
|
950
|
+
const url = `http://localhost:${HTTP_PORT}`;
|
|
951
|
+
writeRuntimeSession({
|
|
952
|
+
port: HTTP_PORT,
|
|
953
|
+
url,
|
|
954
|
+
cwd: PROJECT_DIR,
|
|
955
|
+
terminals: parseInt(process.env.NINJA_TERMINAL_COUNT || '2', 10),
|
|
956
|
+
command: 'ninja-terminals-mcp',
|
|
957
|
+
});
|
|
958
|
+
ownsRuntimeSession = true;
|
|
959
|
+
console.error(`Ninja Terminals HTTP server running on ${url}`);
|
|
727
960
|
|
|
728
961
|
// Auto-spawn terminals based on tier (NINJA_TERMINAL_COUNT env var)
|
|
729
962
|
// Free = 2, Paid = 4
|
|
@@ -750,3 +983,12 @@ main().catch((err) => {
|
|
|
750
983
|
console.error('Fatal error:', err);
|
|
751
984
|
process.exit(1);
|
|
752
985
|
});
|
|
986
|
+
|
|
987
|
+
process.on('exit', () => {
|
|
988
|
+
if (!ownsRuntimeSession) return;
|
|
989
|
+
try {
|
|
990
|
+
clearRuntimeSession();
|
|
991
|
+
} catch {
|
|
992
|
+
// ignore shutdown cleanup failures
|
|
993
|
+
}
|
|
994
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
process.env.NINJA_VISUAL_COMMAND = 'ninja-claude-visual';
|
|
5
|
+
|
|
6
|
+
const { runCli } = require('./ninja-codex-visual');
|
|
7
|
+
|
|
8
|
+
runCli().then((code) => {
|
|
9
|
+
process.exit(code);
|
|
10
|
+
}).catch((err) => {
|
|
11
|
+
console.error(`Error: ${err.message}`);
|
|
12
|
+
process.exit(1);
|
|
13
|
+
});
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const {
|
|
8
|
+
SESSION_DIR,
|
|
9
|
+
VALID_VISUAL_STAGES,
|
|
10
|
+
appendVisualEntry,
|
|
11
|
+
readRuntimeSession,
|
|
12
|
+
} = require('./lib/runtime-session');
|
|
13
|
+
|
|
14
|
+
const USAGE = `
|
|
15
|
+
Usage:
|
|
16
|
+
ninja-codex-visual --record <stage> [--note "..."] [--terminal <id>] [--expect-task <state>] [--screenshot] [--json]
|
|
17
|
+
|
|
18
|
+
Uses Playwright to inspect the local Ninja Terminal UI before recording visual evidence.
|
|
19
|
+
|
|
20
|
+
Examples:
|
|
21
|
+
ninja-codex-visual --record pre-dispatch --note "T1-T4 visible"
|
|
22
|
+
ninja-codex-visual --record post-output --terminal 1 --expect-task done --screenshot
|
|
23
|
+
|
|
24
|
+
Valid stages: ${VALID_VISUAL_STAGES.join(', ')}
|
|
25
|
+
Expected task states: pending, running, done, blocked, error, unknown
|
|
26
|
+
`;
|
|
27
|
+
|
|
28
|
+
const VALID_TASK_EXPECTATIONS = ['pending', 'running', 'done', 'blocked', 'error', 'unknown'];
|
|
29
|
+
|
|
30
|
+
function commandName() {
|
|
31
|
+
if (process.env.NINJA_VISUAL_COMMAND) return process.env.NINJA_VISUAL_COMMAND;
|
|
32
|
+
return path.basename(process.argv[1] || 'ninja-codex-visual', '.js');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function parseArgs(argv) {
|
|
36
|
+
const opts = {
|
|
37
|
+
command: null,
|
|
38
|
+
stage: null,
|
|
39
|
+
note: null,
|
|
40
|
+
terminalId: null,
|
|
41
|
+
expectTask: null,
|
|
42
|
+
screenshot: false,
|
|
43
|
+
json: false,
|
|
44
|
+
url: null,
|
|
45
|
+
timeoutMs: 10000,
|
|
46
|
+
headful: false,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
for (let i = 0; i < argv.length; i++) {
|
|
50
|
+
const arg = argv[i];
|
|
51
|
+
if (arg === '--help' || arg === '-h') {
|
|
52
|
+
opts.command = 'help';
|
|
53
|
+
} else if (arg === '--record') {
|
|
54
|
+
opts.command = 'record';
|
|
55
|
+
opts.stage = argv[++i];
|
|
56
|
+
if (!opts.stage) throw new Error('--record requires a stage');
|
|
57
|
+
} else if (arg === '--note') {
|
|
58
|
+
opts.note = argv[++i];
|
|
59
|
+
if (!opts.note) throw new Error('--note requires text');
|
|
60
|
+
} else if (arg === '--terminal') {
|
|
61
|
+
opts.terminalId = argv[++i];
|
|
62
|
+
if (!opts.terminalId) throw new Error('--terminal requires an id');
|
|
63
|
+
} else if (arg === '--expect-task') {
|
|
64
|
+
opts.expectTask = argv[++i];
|
|
65
|
+
if (!opts.expectTask) throw new Error('--expect-task requires a state');
|
|
66
|
+
} else if (arg === '--screenshot') {
|
|
67
|
+
opts.screenshot = true;
|
|
68
|
+
} else if (arg === '--json') {
|
|
69
|
+
opts.json = true;
|
|
70
|
+
} else if (arg === '--url') {
|
|
71
|
+
opts.url = argv[++i];
|
|
72
|
+
if (!opts.url) throw new Error('--url requires a URL');
|
|
73
|
+
} else if (arg === '--timeout') {
|
|
74
|
+
opts.timeoutMs = Number.parseInt(argv[++i], 10);
|
|
75
|
+
if (!Number.isInteger(opts.timeoutMs) || opts.timeoutMs < 1000) {
|
|
76
|
+
throw new Error('--timeout must be an integer >= 1000');
|
|
77
|
+
}
|
|
78
|
+
} else if (arg === '--headful') {
|
|
79
|
+
opts.headful = true;
|
|
80
|
+
} else {
|
|
81
|
+
throw new Error(`Unknown argument: ${arg}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return opts;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function validateRecordOptions(opts) {
|
|
89
|
+
if (opts.command !== 'record') throw new Error('Only --record is supported');
|
|
90
|
+
if (!VALID_VISUAL_STAGES.includes(opts.stage)) {
|
|
91
|
+
throw new Error(`Invalid stage "${opts.stage}". Valid stages: ${VALID_VISUAL_STAGES.join(', ')}`);
|
|
92
|
+
}
|
|
93
|
+
if (opts.expectTask && !VALID_TASK_EXPECTATIONS.includes(opts.expectTask)) {
|
|
94
|
+
throw new Error(`Invalid --expect-task "${opts.expectTask}". Valid states: ${VALID_TASK_EXPECTATIONS.join(', ')}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function resolveUrl(opts) {
|
|
99
|
+
if (opts.url) return opts.url;
|
|
100
|
+
const session = readRuntimeSession();
|
|
101
|
+
if (!session || !session.url) {
|
|
102
|
+
throw new Error('No Ninja runtime URL found. Run node ninja-ensure.js first.');
|
|
103
|
+
}
|
|
104
|
+
return session.url;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function screenshotPath() {
|
|
108
|
+
const dir = path.join(SESSION_DIR, 'screenshots');
|
|
109
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
110
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
111
|
+
return path.join(dir, `codex-visual-${stamp}.png`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function loadPlaywright() {
|
|
115
|
+
try {
|
|
116
|
+
return require('playwright');
|
|
117
|
+
} catch (err) {
|
|
118
|
+
throw new Error('Playwright is not available. Install or expose playwright before using ninja-codex-visual.');
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function inspectNinjaUi(opts) {
|
|
123
|
+
const { chromium } = loadPlaywright();
|
|
124
|
+
const url = resolveUrl(opts);
|
|
125
|
+
const browser = await chromium.launch({ headless: !opts.headful });
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });
|
|
129
|
+
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: opts.timeoutMs });
|
|
130
|
+
await page.reload({ waitUntil: 'domcontentloaded', timeout: opts.timeoutMs });
|
|
131
|
+
await page.waitForSelector('.terminal-pane', { timeout: opts.timeoutMs });
|
|
132
|
+
await page.waitForTimeout(250);
|
|
133
|
+
|
|
134
|
+
const observation = await page.evaluate(() => {
|
|
135
|
+
const logo = document.querySelector('.logo-text, .logo')?.textContent?.trim() || '';
|
|
136
|
+
const panes = [...document.querySelectorAll('.terminal-pane')].map((pane) => {
|
|
137
|
+
const id = pane.id ? pane.id.replace(/^pane-/, '') : '';
|
|
138
|
+
const rect = pane.getBoundingClientRect();
|
|
139
|
+
return {
|
|
140
|
+
id,
|
|
141
|
+
label: pane.querySelector('.pane-label')?.textContent?.trim() || '',
|
|
142
|
+
processStatus: pane.querySelector('.state-text')?.textContent?.trim().toLowerCase() || '',
|
|
143
|
+
taskStatus: pane.querySelector('.task-text')?.textContent?.trim().toLowerCase() || '',
|
|
144
|
+
visible: rect.width > 0 && rect.height > 0,
|
|
145
|
+
};
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
title: document.title,
|
|
150
|
+
logo,
|
|
151
|
+
terminalCount: panes.length,
|
|
152
|
+
panes,
|
|
153
|
+
};
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
if (!/ninja terminals/i.test(observation.logo) && !/ninja/i.test(observation.title || '')) {
|
|
157
|
+
throw new Error(`Ninja UI not detected at ${url}`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const visiblePanes = observation.panes.filter(p => p.visible);
|
|
161
|
+
if (visiblePanes.length === 0) {
|
|
162
|
+
throw new Error('No visible terminal panes found');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (opts.terminalId) {
|
|
166
|
+
const terminal = observation.panes.find(p => String(p.id) === String(opts.terminalId));
|
|
167
|
+
if (!terminal) throw new Error(`Terminal ${opts.terminalId} not found in UI`);
|
|
168
|
+
if (!terminal.visible) throw new Error(`Terminal ${opts.terminalId} exists but is not visible`);
|
|
169
|
+
if (opts.expectTask && terminal.taskStatus !== opts.expectTask) {
|
|
170
|
+
throw new Error(`Terminal ${opts.terminalId} task status is ${terminal.taskStatus || 'missing'}, expected ${opts.expectTask}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
let shotPath = null;
|
|
175
|
+
if (opts.screenshot) {
|
|
176
|
+
shotPath = screenshotPath();
|
|
177
|
+
await page.screenshot({ path: shotPath, fullPage: true });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
url,
|
|
182
|
+
screenshot: shotPath,
|
|
183
|
+
observation,
|
|
184
|
+
};
|
|
185
|
+
} finally {
|
|
186
|
+
await browser.close();
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function buildNote(opts, inspection) {
|
|
191
|
+
if (opts.note) return opts.note.trim();
|
|
192
|
+
const parts = [
|
|
193
|
+
`Ninja UI visible at ${inspection.url}`,
|
|
194
|
+
`${inspection.observation.terminalCount} terminal pane(s) found`,
|
|
195
|
+
];
|
|
196
|
+
if (opts.terminalId) parts.push(`T${opts.terminalId} visible`);
|
|
197
|
+
if (opts.expectTask) parts.push(`task=${opts.expectTask}`);
|
|
198
|
+
return parts.join(', ');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function runCli(argv = process.argv.slice(2)) {
|
|
202
|
+
const opts = parseArgs(argv);
|
|
203
|
+
if (opts.command === 'help' || !opts.command) {
|
|
204
|
+
console.log(USAGE);
|
|
205
|
+
return opts.command === 'help' ? 0 : 1;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
validateRecordOptions(opts);
|
|
209
|
+
const inspection = await inspectNinjaUi(opts);
|
|
210
|
+
const entry = {
|
|
211
|
+
stage: opts.stage,
|
|
212
|
+
note: buildNote(opts, inspection),
|
|
213
|
+
cwd: process.cwd(),
|
|
214
|
+
command: commandName(),
|
|
215
|
+
source: 'playwright',
|
|
216
|
+
url: inspection.url,
|
|
217
|
+
observed: {
|
|
218
|
+
title: inspection.observation.title,
|
|
219
|
+
logo: inspection.observation.logo,
|
|
220
|
+
terminalCount: inspection.observation.terminalCount,
|
|
221
|
+
panes: inspection.observation.panes,
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
if (opts.terminalId) entry.terminalId = String(opts.terminalId);
|
|
225
|
+
if (opts.expectTask) entry.expectTask = opts.expectTask;
|
|
226
|
+
if (inspection.screenshot) entry.screenshot = inspection.screenshot;
|
|
227
|
+
|
|
228
|
+
const record = appendVisualEntry(entry);
|
|
229
|
+
|
|
230
|
+
if (opts.json) {
|
|
231
|
+
console.log(JSON.stringify(record, null, 2));
|
|
232
|
+
} else {
|
|
233
|
+
console.log(`${commandName()} verification recorded: [${record.stage}]${record.terminalId ? ` T${record.terminalId}` : ''}`);
|
|
234
|
+
console.log(` ${record.note}`);
|
|
235
|
+
if (record.screenshot) console.log(` Screenshot: ${record.screenshot}`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return 0;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (require.main === module) {
|
|
242
|
+
runCli().then((code) => {
|
|
243
|
+
process.exit(code);
|
|
244
|
+
}).catch((err) => {
|
|
245
|
+
console.error(`Error: ${err.message}`);
|
|
246
|
+
process.exit(1);
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
module.exports = {
|
|
251
|
+
VALID_TASK_EXPECTATIONS,
|
|
252
|
+
buildNote,
|
|
253
|
+
commandName,
|
|
254
|
+
inspectNinjaUi,
|
|
255
|
+
parseArgs,
|
|
256
|
+
runCli,
|
|
257
|
+
validateRecordOptions,
|
|
258
|
+
};
|