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.
@@ -0,0 +1,337 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const http = require('http');
5
+ const net = require('net');
6
+ const os = require('os');
7
+ const path = require('path');
8
+ const { spawn } = require('child_process');
9
+
10
+ const SESSION_DIR = path.join(os.homedir(), '.ninja');
11
+ const SESSION_FILE = path.join(SESSION_DIR, 'session.json');
12
+ const TOKEN_FILE = path.join(SESSION_DIR, 'token');
13
+ const LEDGER_FILE = path.join(SESSION_DIR, 'dispatch-ledger.ndjson');
14
+ const VERIFICATION_LEDGER_FILE = path.join(SESSION_DIR, 'verification-ledger.ndjson');
15
+ const VISUAL_LEDGER_FILE = path.join(SESSION_DIR, 'visual-ledger.ndjson');
16
+
17
+ function ensureSessionDir() {
18
+ fs.mkdirSync(SESSION_DIR, { recursive: true, mode: 0o700 });
19
+ }
20
+
21
+ function isPortAvailable(port, host = '127.0.0.1') {
22
+ return new Promise((resolve) => {
23
+ const server = net.createServer();
24
+ server.once('error', () => resolve(false));
25
+ server.once('listening', () => {
26
+ server.close(() => resolve(true));
27
+ });
28
+ server.listen(port, host);
29
+ });
30
+ }
31
+
32
+ async function findAvailablePort(preferredPort, host = '127.0.0.1', maxAttempts = 50) {
33
+ const start = Number.parseInt(preferredPort, 10);
34
+ if (!Number.isInteger(start) || start < 1 || start > 65535) {
35
+ throw new Error(`Invalid preferred port: ${preferredPort}`);
36
+ }
37
+
38
+ for (let port = start; port < start + maxAttempts && port <= 65535; port++) {
39
+ if (await isPortAvailable(port, host)) return port;
40
+ }
41
+
42
+ throw new Error(`No available port found from ${start} to ${Math.min(start + maxAttempts - 1, 65535)}`);
43
+ }
44
+
45
+ function writeRuntimeSession(session) {
46
+ ensureSessionDir();
47
+ const payload = {
48
+ version: 1,
49
+ pid: process.pid,
50
+ host: 'localhost',
51
+ createdAt: new Date().toISOString(),
52
+ ...session,
53
+ };
54
+ fs.writeFileSync(SESSION_FILE, JSON.stringify(payload, null, 2) + '\n', { mode: 0o600 });
55
+ return payload;
56
+ }
57
+
58
+ function updateRuntimeSession(patch) {
59
+ const current = readRuntimeSession() || {};
60
+ return writeRuntimeSession({ ...current, ...patch, updatedAt: new Date().toISOString() });
61
+ }
62
+
63
+ function readRuntimeSession() {
64
+ try {
65
+ const raw = fs.readFileSync(SESSION_FILE, 'utf8');
66
+ return JSON.parse(raw);
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
71
+
72
+ function clearRuntimeSession() {
73
+ try {
74
+ fs.unlinkSync(SESSION_FILE);
75
+ } catch {
76
+ // already absent
77
+ }
78
+ }
79
+
80
+ function writeAuthToken(token) {
81
+ ensureSessionDir();
82
+ fs.writeFileSync(TOKEN_FILE, token, { mode: 0o600 });
83
+ }
84
+
85
+ function readAuthToken() {
86
+ try {
87
+ return fs.readFileSync(TOKEN_FILE, 'utf8').trim();
88
+ } catch {
89
+ return null;
90
+ }
91
+ }
92
+
93
+ function requestJson({ host = 'localhost', port, path: reqPath, token, method = 'GET', body = null, timeoutMs = 3000 }) {
94
+ return new Promise((resolve, reject) => {
95
+ const payload = body ? JSON.stringify(body) : null;
96
+ const headers = {};
97
+ if (payload) {
98
+ headers['Content-Type'] = 'application/json';
99
+ headers['Content-Length'] = Buffer.byteLength(payload);
100
+ }
101
+ if (token) headers.Authorization = `Bearer ${token}`;
102
+
103
+ const req = http.request({
104
+ hostname: host,
105
+ port,
106
+ path: reqPath,
107
+ method,
108
+ headers,
109
+ timeout: timeoutMs,
110
+ }, (res) => {
111
+ let data = '';
112
+ res.on('data', chunk => { data += chunk; });
113
+ res.on('end', () => {
114
+ let parsed = null;
115
+ try {
116
+ parsed = data ? JSON.parse(data) : null;
117
+ } catch {
118
+ parsed = data;
119
+ }
120
+ resolve({ statusCode: res.statusCode, body: parsed, raw: data });
121
+ });
122
+ });
123
+
124
+ req.on('timeout', () => {
125
+ req.destroy(new Error(`Request timed out after ${timeoutMs}ms`));
126
+ });
127
+ req.on('error', reject);
128
+ if (payload) req.write(payload);
129
+ req.end();
130
+ });
131
+ }
132
+
133
+ async function healthCheckSession(session, timeoutMs = 3000) {
134
+ if (!session || !session.port) return { ok: false, error: 'No runtime session port' };
135
+ try {
136
+ const res = await requestJson({
137
+ host: session.host || 'localhost',
138
+ port: session.port,
139
+ path: '/health',
140
+ timeoutMs,
141
+ });
142
+ if (
143
+ res.statusCode >= 200 &&
144
+ res.statusCode < 300 &&
145
+ res.body &&
146
+ res.body.status === 'ok' &&
147
+ Object.prototype.hasOwnProperty.call(res.body, 'terminals')
148
+ ) {
149
+ return { ok: true, response: res.body };
150
+ }
151
+ return { ok: false, error: `HTTP ${res.statusCode}`, response: res.body };
152
+ } catch (err) {
153
+ return { ok: false, error: err.message };
154
+ }
155
+ }
156
+
157
+ function openBrowserTab(url) {
158
+ // Opt-in: only open browser if NINJA_OPEN_BROWSER=true
159
+ if (process.env.NINJA_OPEN_BROWSER !== '1' && process.env.NINJA_OPEN_BROWSER !== 'true') {
160
+ return false;
161
+ }
162
+
163
+ if (process.platform === 'darwin') {
164
+ const child = spawn('open', ['-a', 'Google Chrome', url], {
165
+ detached: true,
166
+ stdio: 'ignore',
167
+ });
168
+ child.on('error', () => {
169
+ const fallback = spawn('open', [url], { detached: true, stdio: 'ignore' });
170
+ fallback.unref();
171
+ });
172
+ child.unref();
173
+ return true;
174
+ }
175
+
176
+ const opener = process.platform === 'win32' ? 'start' : 'xdg-open';
177
+ const args = process.platform === 'win32' ? ['', url] : [url];
178
+ const child = spawn(opener, args, { detached: true, stdio: 'ignore', shell: process.platform === 'win32' });
179
+ child.unref();
180
+ return true;
181
+ }
182
+
183
+ // ── Dispatch Ledger ──────────────────────────────────────────
184
+
185
+ function appendLedgerEntry(entry) {
186
+ ensureSessionDir();
187
+ const record = {
188
+ timestamp: new Date().toISOString(),
189
+ ...entry,
190
+ };
191
+ fs.appendFileSync(LEDGER_FILE, JSON.stringify(record) + '\n', { mode: 0o600 });
192
+ return record;
193
+ }
194
+
195
+ function readLedgerEntries(limit = 50) {
196
+ try {
197
+ const raw = fs.readFileSync(LEDGER_FILE, 'utf8');
198
+ const lines = raw.trim().split('\n').filter(Boolean);
199
+ const entries = lines.map((line) => {
200
+ try {
201
+ return JSON.parse(line);
202
+ } catch {
203
+ return null;
204
+ }
205
+ }).filter(Boolean);
206
+ // Return last N entries (most recent)
207
+ return entries.slice(-limit);
208
+ } catch {
209
+ return [];
210
+ }
211
+ }
212
+
213
+ function clearLedger() {
214
+ try {
215
+ fs.unlinkSync(LEDGER_FILE);
216
+ } catch {
217
+ // already absent
218
+ }
219
+ }
220
+
221
+ // ── Verification Ledger ──────────────────────────────────────────
222
+ // Records output/status checks after dispatch
223
+
224
+ function appendVerificationEntry(entry) {
225
+ ensureSessionDir();
226
+ const record = {
227
+ timestamp: new Date().toISOString(),
228
+ ...entry,
229
+ };
230
+ fs.appendFileSync(VERIFICATION_LEDGER_FILE, JSON.stringify(record) + '\n', { mode: 0o600 });
231
+ return record;
232
+ }
233
+
234
+ function readVerificationEntries(limit = 50) {
235
+ try {
236
+ const raw = fs.readFileSync(VERIFICATION_LEDGER_FILE, 'utf8');
237
+ const lines = raw.trim().split('\n').filter(Boolean);
238
+ const entries = lines.map((line) => {
239
+ try {
240
+ return JSON.parse(line);
241
+ } catch {
242
+ return null;
243
+ }
244
+ }).filter(Boolean);
245
+ return entries.slice(-limit);
246
+ } catch {
247
+ return [];
248
+ }
249
+ }
250
+
251
+ function clearVerificationLedger() {
252
+ try {
253
+ fs.unlinkSync(VERIFICATION_LEDGER_FILE);
254
+ } catch {
255
+ // already absent
256
+ }
257
+ }
258
+
259
+ // ── Visual Ledger ──────────────────────────────────────────
260
+ // Records browser visual inspections via claude-in-chrome
261
+
262
+ const VALID_VISUAL_STAGES = ['pre-dispatch', 'post-dispatch', 'post-output', 'final-visual'];
263
+
264
+ function appendVisualEntry(entry) {
265
+ ensureSessionDir();
266
+ const record = {
267
+ timestamp: new Date().toISOString(),
268
+ ...entry,
269
+ };
270
+ fs.appendFileSync(VISUAL_LEDGER_FILE, JSON.stringify(record) + '\n', { mode: 0o600 });
271
+ return record;
272
+ }
273
+
274
+ function readVisualEntries(limit = 50) {
275
+ try {
276
+ const raw = fs.readFileSync(VISUAL_LEDGER_FILE, 'utf8');
277
+ const lines = raw.trim().split('\n').filter(Boolean);
278
+ const entries = lines.map((line) => {
279
+ try {
280
+ return JSON.parse(line);
281
+ } catch {
282
+ return null;
283
+ }
284
+ }).filter(Boolean);
285
+ return entries.slice(-limit);
286
+ } catch {
287
+ return [];
288
+ }
289
+ }
290
+
291
+ function clearVisualLedger() {
292
+ try {
293
+ fs.unlinkSync(VISUAL_LEDGER_FILE);
294
+ } catch {
295
+ // already absent
296
+ }
297
+ }
298
+
299
+ function hasVisualAfter(afterTimestamp, options = {}) {
300
+ const entries = readVisualEntries(100);
301
+ return entries.some(e => {
302
+ if (!e.timestamp || e.timestamp <= afterTimestamp) return false;
303
+ if (options.stage && e.stage !== options.stage) return false;
304
+ if (options.terminalId && e.terminalId !== options.terminalId) return false;
305
+ return true;
306
+ });
307
+ }
308
+
309
+ module.exports = {
310
+ SESSION_DIR,
311
+ SESSION_FILE,
312
+ TOKEN_FILE,
313
+ LEDGER_FILE,
314
+ VERIFICATION_LEDGER_FILE,
315
+ VISUAL_LEDGER_FILE,
316
+ VALID_VISUAL_STAGES,
317
+ findAvailablePort,
318
+ writeRuntimeSession,
319
+ updateRuntimeSession,
320
+ readRuntimeSession,
321
+ clearRuntimeSession,
322
+ writeAuthToken,
323
+ readAuthToken,
324
+ healthCheckSession,
325
+ requestJson,
326
+ openBrowserTab,
327
+ appendLedgerEntry,
328
+ readLedgerEntries,
329
+ clearLedger,
330
+ appendVerificationEntry,
331
+ readVerificationEntries,
332
+ clearVerificationLedger,
333
+ appendVisualEntry,
334
+ readVisualEntries,
335
+ clearVisualLedger,
336
+ hasVisualAfter,
337
+ };
@@ -37,7 +37,7 @@ const SPINNER_RE = /[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]|Thinking|Generating/i;
37
37
  * Detect the current operational status of a Claude Code terminal.
38
38
  *
39
39
  * @param {string[]} lines - Array of ANSI-stripped lines (from LineBuffer)
40
- * @returns {'idle'|'working'|'waiting_approval'|'compacting'|'done'|'blocked'|'error'}
40
+ * @returns {'idle'|'working'|'waiting_approval'|'compacting'|'done'|'blocked'|'error'|'exited'}
41
41
  */
42
42
  function detectStatus(lines) {
43
43
  if (!lines || lines.length === 0) return 'idle';
@@ -51,8 +51,18 @@ function detectStatus(lines) {
51
51
  const lastContentLine = contentLines.slice(-1)[0] || '';
52
52
  const last10 = trimmed.slice(-10);
53
53
 
54
+ // Closed or disconnected PTY session
55
+ if (/\[Connection closed\]|Connection closed/i.test(last50)) {
56
+ return 'exited';
57
+ }
58
+
59
+ // Rate-limit and explicit blocking menus are not active work
60
+ if (/out of (extra )?usage|rate-limit-options|Stop and wait for limit to reset|Upgrade your plan/i.test(last50)) {
61
+ return 'blocked';
62
+ }
63
+
54
64
  // Prompt detection — idle if prompt is visible and no recent tool work
55
- const hasPrompt = last10.some(l => /^[>❯]$/.test(l));
65
+ const hasPrompt = last10.some(l => /^[>❯]/.test(l) || /Try\s+"[^"]+"/i.test(l));
56
66
  const hasShortcutsHint = last10.some(
57
67
  l => /\?.*for\s*shortcuts/i.test(l) || l === '?forshortcuts'
58
68
  );
@@ -74,8 +84,9 @@ function detectStatus(lines) {
74
84
  if (/auto-compact|compressing|compacting/i.test(last50)) return 'compacting';
75
85
 
76
86
  // Explicit status markers (convention for orchestrator scripts)
77
- if (/STATUS: DONE/i.test(last50)) return 'done';
78
- if (/STATUS: BLOCKED/i.test(last50)) return 'blocked';
87
+ if (/^\s*STATUS:\s*DONE/im.test(last50)) return 'done';
88
+ if (/^\s*STATUS:\s*BLOCKED/im.test(last50)) return 'blocked';
89
+ if (/^\s*STATUS:\s*ERROR/im.test(last50)) return 'error';
79
90
 
80
91
  // Active tool use
81
92
  if (TOOL_RE.test(last50)) return 'working';
@@ -221,9 +232,62 @@ function extractStructuredEvents(lines, terminalLabel) {
221
232
  return events;
222
233
  }
223
234
 
235
+ // ---------------------------------------------------------------------------
236
+ // Task Status Parsing (semantic status from worker output)
237
+ // ---------------------------------------------------------------------------
238
+
239
+ /**
240
+ * Valid task status states.
241
+ * @type {string[]}
242
+ */
243
+ const VALID_TASK_STATES = ['pending', 'running', 'done', 'blocked', 'error', 'unknown'];
244
+
245
+ /**
246
+ * Regex patterns for parsing STATUS: markers.
247
+ * Captures the status type and optional message.
248
+ */
249
+ const TASK_STATUS_PATTERNS = [
250
+ { re: /^[\s⏺✢✳✶✻✽·]*STATUS:\s*DONE\s*[-—]?\s*(.*)/i, state: 'done' },
251
+ { re: /^[\s⏺✢✳✶✻✽·]*STATUS:\s*BLOCKED\s*[-—]?\s*(.*)/i, state: 'blocked' },
252
+ { re: /^[\s⏺✢✳✶✻✽·]*STATUS:\s*ERROR[:\s]*[-—]?\s*(.*)/i, state: 'error' },
253
+ { re: /^[\s⏺✢✳✶✻✽·]*STATUS:\s*RUNNING\s*[-—]?\s*(.*)/i, state: 'running' },
254
+ ];
255
+
256
+ /**
257
+ * Parse terminal output for the most recent STATUS: marker.
258
+ * Scans from end to find the latest semantic task status.
259
+ *
260
+ * @param {string[]} lines - Array of ANSI-stripped lines
261
+ * @returns {{ state: string, marker: string, message: string } | null}
262
+ */
263
+ function parseTaskStatus(lines) {
264
+ if (!lines || lines.length === 0) return null;
265
+
266
+ // Scan from the end — most recent status wins
267
+ for (let i = lines.length - 1; i >= 0; i--) {
268
+ const line = lines[i].trim();
269
+ if (!line) continue;
270
+
271
+ for (const { re, state } of TASK_STATUS_PATTERNS) {
272
+ const match = line.match(re);
273
+ if (match) {
274
+ return {
275
+ state,
276
+ marker: match[0].trim(),
277
+ message: match[1]?.trim() || '',
278
+ };
279
+ }
280
+ }
281
+ }
282
+
283
+ return null;
284
+ }
285
+
224
286
  module.exports = {
225
287
  stripAnsi,
226
288
  detectStatus,
227
289
  extractContextPct,
228
290
  extractStructuredEvents,
291
+ parseTaskStatus,
292
+ VALID_TASK_STATES,
229
293
  };