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/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 HTTP_PORT = parseInt(process.env.HTTP_PORT || '3300', 10);
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
- const workDir = cwd || PROJECT_DIR;
96
- const settingsDir = cwd || PROJECT_DIR;
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
- terminal.pty.write(finalText);
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.pty.write(`${args.description}\r`);
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
- // Check if server.js is already running on port 3300
716
- const portInUse = await isPortInUse(HTTP_PORT);
933
+ const runtimeSession = readRuntimeSession();
934
+ const runtimeHealth = await healthCheckSession(runtimeSession);
717
935
 
718
- if (portInUse) {
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
- console.error(`Ninja Terminals HTTP server running on http://localhost:${HTTP_PORT}`);
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
+ };