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/ninja-codex.js ADDED
@@ -0,0 +1,474 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const { spawnSync } = require('child_process');
5
+ const path = require('path');
6
+ const {
7
+ readAuthToken,
8
+ readRuntimeSession,
9
+ requestJson,
10
+ } = require('./lib/runtime-session');
11
+
12
+ const USAGE = `
13
+ Usage:
14
+ ninja-codex ensure [args...]
15
+ ninja-codex visual [args...]
16
+ ninja-codex dispatch <terminal-id> <message...>
17
+ ninja-codex status
18
+ ninja-codex task-status [terminal-id]
19
+ ninja-codex output <terminal-id> [--lines N]
20
+ ninja-codex gate [--since <duration|iso>] [--json]
21
+ ninja-codex run --terminal <id> --task "<message>" [--timeout 120000] [--expect-task done] [--lines 80]
22
+ ninja-codex squad --task "<message>" [--terminals 1,2,3,4] [--roles implement,test,review,docs] [--timeout 180000]
23
+
24
+ Codex wrapper around the Ninja evidence loop.
25
+
26
+ The run workflow:
27
+ 1. ninja-ensure
28
+ 2. ninja-codex-visual --record pre-dispatch
29
+ 3. agent-send <terminal-id> <task>
30
+ 4. wait for semantic task status done/blocked/error
31
+ 5. agent-send --task-status <terminal-id>
32
+ 6. agent-send --output <terminal-id>
33
+ 7. ninja-codex-visual --record post-output --terminal <id> --expect-task <state>
34
+ 8. ninja-gate --since <workflow start timestamp>
35
+
36
+ The squad workflow dispatches role-scoped variants of one task to multiple
37
+ terminals, waits for all semantic task statuses to finish, collects output from
38
+ each terminal, records per-terminal visual evidence, then runs ninja-gate.
39
+ `;
40
+
41
+ const COMPLETE_TASK_STATES = ['done', 'blocked', 'error'];
42
+ const READY_PROCESS_STATES = ['idle', 'stuck', 'done', 'blocked', 'error'];
43
+ const DEFAULT_SQUAD_TERMINALS = ['1', '2', '3', '4'];
44
+ const DEFAULT_SQUAD_ROLES = ['implementer', 'tester', 'reviewer', 'documenter'];
45
+
46
+ function script(name) {
47
+ return path.join(__dirname, name);
48
+ }
49
+
50
+ function runNode(scriptName, args, options = {}) {
51
+ const res = spawnSync(process.execPath, [script(scriptName), ...args], {
52
+ cwd: __dirname,
53
+ stdio: options.capture ? ['ignore', 'pipe', 'pipe'] : 'inherit',
54
+ encoding: 'utf8',
55
+ });
56
+
57
+ if (options.capture) {
58
+ return res;
59
+ }
60
+
61
+ if (res.status !== 0) {
62
+ process.exit(res.status || 1);
63
+ }
64
+ return res;
65
+ }
66
+
67
+ function parseRunArgs(argv) {
68
+ const opts = {
69
+ terminalId: null,
70
+ task: null,
71
+ timeoutMs: 120000,
72
+ readyTimeoutMs: 60000,
73
+ pollMs: 2000,
74
+ expectTask: 'done',
75
+ lines: 80,
76
+ screenshot: false,
77
+ noOpen: false,
78
+ };
79
+
80
+ for (let i = 0; i < argv.length; i++) {
81
+ const arg = argv[i];
82
+ if (arg === '--terminal') {
83
+ opts.terminalId = argv[++i];
84
+ } else if (arg === '--task') {
85
+ opts.task = argv[++i];
86
+ } else if (arg === '--timeout') {
87
+ opts.timeoutMs = Number.parseInt(argv[++i], 10);
88
+ } else if (arg === '--ready-timeout') {
89
+ opts.readyTimeoutMs = Number.parseInt(argv[++i], 10);
90
+ } else if (arg === '--poll') {
91
+ opts.pollMs = Number.parseInt(argv[++i], 10);
92
+ } else if (arg === '--expect-task') {
93
+ opts.expectTask = argv[++i];
94
+ } else if (arg === '--lines') {
95
+ opts.lines = Number.parseInt(argv[++i], 10);
96
+ } else if (arg === '--screenshot') {
97
+ opts.screenshot = true;
98
+ } else if (arg === '--no-open') {
99
+ opts.noOpen = true;
100
+ } else {
101
+ throw new Error(`Unknown run argument: ${arg}`);
102
+ }
103
+ }
104
+
105
+ if (!opts.terminalId || !/^\d+$/.test(String(opts.terminalId))) {
106
+ throw new Error('run requires --terminal <numeric-id>');
107
+ }
108
+ if (!opts.task) throw new Error('run requires --task "<message>"');
109
+ if (!Number.isInteger(opts.timeoutMs) || opts.timeoutMs < 1000) {
110
+ throw new Error('--timeout must be an integer >= 1000');
111
+ }
112
+ if (!Number.isInteger(opts.readyTimeoutMs) || opts.readyTimeoutMs < 1000) {
113
+ throw new Error('--ready-timeout must be an integer >= 1000');
114
+ }
115
+ if (!Number.isInteger(opts.pollMs) || opts.pollMs < 250) {
116
+ throw new Error('--poll must be an integer >= 250');
117
+ }
118
+ if (!Number.isInteger(opts.lines) || opts.lines < 1) {
119
+ throw new Error('--lines must be an integer >= 1');
120
+ }
121
+
122
+ return opts;
123
+ }
124
+
125
+ function parseList(value, flagName) {
126
+ if (!value) throw new Error(`${flagName} requires a comma-separated value`);
127
+ return String(value)
128
+ .split(',')
129
+ .map(v => v.trim())
130
+ .filter(Boolean);
131
+ }
132
+
133
+ function parseSquadArgs(argv) {
134
+ const opts = {
135
+ terminalIds: DEFAULT_SQUAD_TERMINALS.slice(),
136
+ roles: DEFAULT_SQUAD_ROLES.slice(),
137
+ task: null,
138
+ timeoutMs: 180000,
139
+ readyTimeoutMs: 120000,
140
+ pollMs: 2000,
141
+ expectTask: null,
142
+ lines: 80,
143
+ screenshot: false,
144
+ noOpen: false,
145
+ };
146
+
147
+ for (let i = 0; i < argv.length; i++) {
148
+ const arg = argv[i];
149
+ if (arg === '--terminals') {
150
+ opts.terminalIds = parseList(argv[++i], '--terminals');
151
+ } else if (arg === '--roles') {
152
+ opts.roles = parseList(argv[++i], '--roles');
153
+ } else if (arg === '--task') {
154
+ opts.task = argv[++i];
155
+ } else if (arg === '--timeout') {
156
+ opts.timeoutMs = Number.parseInt(argv[++i], 10);
157
+ } else if (arg === '--ready-timeout') {
158
+ opts.readyTimeoutMs = Number.parseInt(argv[++i], 10);
159
+ } else if (arg === '--poll') {
160
+ opts.pollMs = Number.parseInt(argv[++i], 10);
161
+ } else if (arg === '--expect-task') {
162
+ opts.expectTask = argv[++i];
163
+ } else if (arg === '--lines') {
164
+ opts.lines = Number.parseInt(argv[++i], 10);
165
+ } else if (arg === '--screenshot') {
166
+ opts.screenshot = true;
167
+ } else if (arg === '--no-open') {
168
+ opts.noOpen = true;
169
+ } else {
170
+ throw new Error(`Unknown squad argument: ${arg}`);
171
+ }
172
+ }
173
+
174
+ if (opts.terminalIds.length === 0) throw new Error('squad requires at least one terminal');
175
+ for (const terminalId of opts.terminalIds) {
176
+ if (!/^\d+$/.test(String(terminalId))) {
177
+ throw new Error(`--terminals must contain numeric ids, got: ${terminalId}`);
178
+ }
179
+ }
180
+ if (!opts.task) throw new Error('squad requires --task "<message>"');
181
+ if (!Number.isInteger(opts.timeoutMs) || opts.timeoutMs < 1000) {
182
+ throw new Error('--timeout must be an integer >= 1000');
183
+ }
184
+ if (!Number.isInteger(opts.readyTimeoutMs) || opts.readyTimeoutMs < 1000) {
185
+ throw new Error('--ready-timeout must be an integer >= 1000');
186
+ }
187
+ if (!Number.isInteger(opts.pollMs) || opts.pollMs < 250) {
188
+ throw new Error('--poll must be an integer >= 250');
189
+ }
190
+ if (!Number.isInteger(opts.lines) || opts.lines < 1) {
191
+ throw new Error('--lines must be an integer >= 1');
192
+ }
193
+
194
+ return opts;
195
+ }
196
+
197
+ function squadPrompt({ role, terminalId, task }) {
198
+ return [
199
+ `You are T${terminalId}, the ${role} in a Codex terminal squad.`,
200
+ `Task: ${task}`,
201
+ '',
202
+ 'Work independently in your assigned role. Keep changes scoped to your role.',
203
+ 'When finished, print exactly one final status line at the start of a new line.',
204
+ 'The final line format is: the word STATUS, then a colon, then DONE, then a short result.',
205
+ '',
206
+ 'If blocked, use the same format with BLOCKED instead of DONE and include a short reason.',
207
+ ].join('\n');
208
+ }
209
+
210
+ function sleep(ms) {
211
+ return new Promise(resolve => setTimeout(resolve, ms));
212
+ }
213
+
214
+ function getToken(session) {
215
+ return process.env.NINJA_AUTH_TOKEN || session?.authToken || readAuthToken();
216
+ }
217
+
218
+ function resolveRuntimeSession() {
219
+ const session = readRuntimeSession() || {};
220
+ const port = process.env.NINJA_PORT || session.port;
221
+ if (!port) throw new Error('No Ninja session found. Run ninja-codex ensure first.');
222
+ return {
223
+ ...session,
224
+ host: process.env.NINJA_HOST || session.host || 'localhost',
225
+ port: Number.parseInt(port, 10),
226
+ authToken: process.env.NINJA_AUTH_TOKEN || session.authToken,
227
+ };
228
+ }
229
+
230
+ function primeRuntimeEnv() {
231
+ const session = resolveRuntimeSession();
232
+ process.env.NINJA_HOST = session.host || 'localhost';
233
+ process.env.NINJA_PORT = String(session.port);
234
+ const token = getToken(session);
235
+ if (token) process.env.NINJA_AUTH_TOKEN = token;
236
+ return session;
237
+ }
238
+
239
+ async function fetchTaskStatus(terminalId) {
240
+ const session = resolveRuntimeSession();
241
+ const token = getToken(session);
242
+ if (!token) throw new Error('No Ninja auth token. Run: ninja-login');
243
+
244
+ const res = await requestJson({
245
+ host: session.host || 'localhost',
246
+ port: session.port,
247
+ path: `/api/terminals/${terminalId}/task-status`,
248
+ token,
249
+ timeoutMs: 3000,
250
+ });
251
+
252
+ if (res.statusCode !== 200 || !res.body) {
253
+ throw new Error(`Task status API failed: HTTP ${res.statusCode}`);
254
+ }
255
+ return res.body;
256
+ }
257
+
258
+ async function fetchProcessStatus(terminalId) {
259
+ const session = resolveRuntimeSession();
260
+ const token = getToken(session);
261
+ if (!token) throw new Error('No Ninja auth token. Run: ninja-login');
262
+
263
+ const res = await requestJson({
264
+ host: session.host || 'localhost',
265
+ port: session.port,
266
+ path: `/api/terminals/${terminalId}/status`,
267
+ token,
268
+ timeoutMs: 3000,
269
+ });
270
+
271
+ if (res.statusCode !== 200 || !res.body) {
272
+ throw new Error(`Process status API failed: HTTP ${res.statusCode}`);
273
+ }
274
+ return res.body;
275
+ }
276
+
277
+ async function fetchOutputLines(terminalId, lines = 40) {
278
+ const session = resolveRuntimeSession();
279
+ const token = getToken(session);
280
+ if (!token) throw new Error('No Ninja auth token. Run: ninja-login');
281
+
282
+ const res = await requestJson({
283
+ host: session.host || 'localhost',
284
+ port: session.port,
285
+ path: `/api/terminals/${terminalId}/output?lines=${lines}`,
286
+ token,
287
+ timeoutMs: 3000,
288
+ });
289
+
290
+ if (res.statusCode !== 200 || !res.body) return [];
291
+ return Array.isArray(res.body.lines) ? res.body.lines : [];
292
+ }
293
+
294
+ function hasPromptVisible(lines) {
295
+ const text = lines.join('\n');
296
+ return text.includes('❯') && text.includes('⏵⏵');
297
+ }
298
+
299
+ async function waitForTerminalReady(terminalId, opts) {
300
+ const deadline = Date.now() + opts.readyTimeoutMs;
301
+ let last = null;
302
+
303
+ while (Date.now() < deadline) {
304
+ last = await fetchProcessStatus(terminalId);
305
+ if (last.status && READY_PROCESS_STATES.includes(last.status)) {
306
+ return last;
307
+ }
308
+ const lines = await fetchOutputLines(terminalId, 40);
309
+ if (hasPromptVisible(lines)) {
310
+ return { ...last, readiness: 'prompt-visible' };
311
+ }
312
+ await sleep(opts.pollMs);
313
+ }
314
+
315
+ throw new Error(`Timed out waiting for T${terminalId} to become dispatch-ready. Last status: ${last?.status || 'unknown'}`);
316
+ }
317
+
318
+ async function waitForTaskComplete(terminalId, opts, afterTs) {
319
+ const deadline = Date.now() + opts.timeoutMs;
320
+ let last = null;
321
+
322
+ while (Date.now() < deadline) {
323
+ last = await fetchTaskStatus(terminalId);
324
+ const state = last.taskStatus || 'unknown';
325
+ const updatedAt = last.updatedAt || last.taskStatusUpdatedAt || last.timestamp || null;
326
+ if (COMPLETE_TASK_STATES.includes(state) && updatedAt && updatedAt > afterTs) return last;
327
+ await sleep(opts.pollMs);
328
+ }
329
+
330
+ throw new Error(`Timed out waiting for T${terminalId} task completion. Last status: ${last?.taskStatus || 'unknown'}`);
331
+ }
332
+
333
+ async function runWorkflow(argv) {
334
+ const opts = parseRunArgs(argv);
335
+ const sinceTs = new Date().toISOString();
336
+
337
+ runNode('ninja-ensure.js', opts.noOpen ? ['--no-open'] : []);
338
+ primeRuntimeEnv();
339
+ const readyStatus = await waitForTerminalReady(opts.terminalId, opts);
340
+ console.log(`T${opts.terminalId} process status ready: ${readyStatus.status}`);
341
+ runNode('ninja-codex-visual.js', [
342
+ '--record', 'pre-dispatch',
343
+ '--note', `Codex workflow pre-dispatch: T${opts.terminalId} target visible`,
344
+ ]);
345
+ const dispatchTs = new Date().toISOString();
346
+ runNode('agent-send.js', [String(opts.terminalId), opts.task]);
347
+
348
+ const finalStatus = await waitForTaskComplete(opts.terminalId, opts, dispatchTs);
349
+ console.log(`T${opts.terminalId} semantic task status: ${finalStatus.taskStatus}`);
350
+
351
+ runNode('agent-send.js', ['--task-status', String(opts.terminalId)]);
352
+ runNode('agent-send.js', ['--output', String(opts.terminalId), '--lines', String(opts.lines)]);
353
+
354
+ const visualArgs = [
355
+ '--record', 'post-output',
356
+ '--terminal', String(opts.terminalId),
357
+ '--expect-task', opts.expectTask || finalStatus.taskStatus,
358
+ '--note', `Codex workflow post-output: T${opts.terminalId} task ${finalStatus.taskStatus}`,
359
+ ];
360
+ if (opts.screenshot) visualArgs.push('--screenshot');
361
+ runNode('ninja-codex-visual.js', visualArgs);
362
+
363
+ runNode('ninja-gate.js', ['--since', sinceTs]);
364
+ }
365
+
366
+ async function runSquadWorkflow(argv) {
367
+ const opts = parseSquadArgs(argv);
368
+ const sinceTs = new Date().toISOString();
369
+
370
+ runNode('ninja-ensure.js', opts.noOpen ? ['--no-open'] : []);
371
+ primeRuntimeEnv();
372
+
373
+ console.log(`Waiting for squad readiness: ${opts.terminalIds.map(id => `T${id}`).join(', ')}`);
374
+ const readyResults = await Promise.all(opts.terminalIds.map(async (terminalId) => {
375
+ const status = await waitForTerminalReady(terminalId, opts);
376
+ return { terminalId, status };
377
+ }));
378
+ for (const result of readyResults) {
379
+ const readiness = result.status.readiness ? ` (${result.status.readiness})` : '';
380
+ console.log(`T${result.terminalId} process status ready: ${result.status.status}${readiness}`);
381
+ }
382
+
383
+ runNode('ninja-codex-visual.js', [
384
+ '--record', 'pre-dispatch',
385
+ '--note', `Codex squad pre-dispatch: ${opts.terminalIds.map(id => `T${id}`).join(', ')} visible`,
386
+ ]);
387
+
388
+ const dispatchTs = new Date().toISOString();
389
+ for (let i = 0; i < opts.terminalIds.length; i++) {
390
+ const terminalId = String(opts.terminalIds[i]);
391
+ const role = opts.roles[i] || `worker-${terminalId}`;
392
+ runNode('agent-send.js', [terminalId, squadPrompt({ role, terminalId, task: opts.task })]);
393
+ }
394
+
395
+ const finalStatuses = await Promise.all(opts.terminalIds.map(async (terminalId) => {
396
+ const finalStatus = await waitForTaskComplete(terminalId, opts, dispatchTs);
397
+ console.log(`T${terminalId} semantic task status: ${finalStatus.taskStatus}`);
398
+ return { terminalId: String(terminalId), finalStatus };
399
+ }));
400
+
401
+ runNode('agent-send.js', ['--task-status']);
402
+ for (const { terminalId, finalStatus } of finalStatuses) {
403
+ runNode('agent-send.js', ['--output', terminalId, '--lines', String(opts.lines)]);
404
+
405
+ const visualArgs = [
406
+ '--record', 'post-output',
407
+ '--terminal', terminalId,
408
+ '--expect-task', opts.expectTask || finalStatus.taskStatus,
409
+ '--note', `Codex squad post-output: T${terminalId} task ${finalStatus.taskStatus}`,
410
+ ];
411
+ if (opts.screenshot) visualArgs.push('--screenshot');
412
+ runNode('ninja-codex-visual.js', visualArgs);
413
+ }
414
+
415
+ runNode('ninja-gate.js', ['--since', sinceTs]);
416
+ }
417
+
418
+ async function main() {
419
+ const args = process.argv.slice(2);
420
+ const cmd = args[0];
421
+ const rest = args.slice(1);
422
+
423
+ if (!cmd || cmd === '--help' || cmd === '-h') {
424
+ console.log(USAGE);
425
+ process.exit(cmd ? 0 : 1);
426
+ }
427
+
428
+ if (cmd === 'ensure') {
429
+ runNode('ninja-ensure.js', rest);
430
+ } else if (cmd === 'visual') {
431
+ runNode('ninja-codex-visual.js', rest);
432
+ } else if (cmd === 'dispatch') {
433
+ if (rest.length < 2) throw new Error('dispatch requires <terminal-id> <message...>');
434
+ runNode('agent-send.js', [rest[0], rest.slice(1).join(' ')]);
435
+ } else if (cmd === 'status') {
436
+ runNode('agent-send.js', ['--status']);
437
+ } else if (cmd === 'task-status') {
438
+ runNode('agent-send.js', ['--task-status', ...rest]);
439
+ } else if (cmd === 'output') {
440
+ if (!rest[0]) throw new Error('output requires <terminal-id>');
441
+ runNode('agent-send.js', ['--output', ...rest]);
442
+ } else if (cmd === 'gate') {
443
+ runNode('ninja-gate.js', rest);
444
+ } else if (cmd === 'run') {
445
+ await runWorkflow(rest);
446
+ } else if (cmd === 'squad') {
447
+ await runSquadWorkflow(rest);
448
+ } else {
449
+ throw new Error(`Unknown command: ${cmd}`);
450
+ }
451
+ }
452
+
453
+ if (require.main === module) {
454
+ main().catch((err) => {
455
+ console.error(`Error: ${err.message}`);
456
+ process.exit(1);
457
+ });
458
+ }
459
+
460
+ module.exports = {
461
+ COMPLETE_TASK_STATES,
462
+ READY_PROCESS_STATES,
463
+ DEFAULT_SQUAD_ROLES,
464
+ DEFAULT_SQUAD_TERMINALS,
465
+ hasPromptVisible,
466
+ fetchProcessStatus,
467
+ parseRunArgs,
468
+ parseSquadArgs,
469
+ primeRuntimeEnv,
470
+ resolveRuntimeSession,
471
+ squadPrompt,
472
+ waitForTerminalReady,
473
+ waitForTaskComplete,
474
+ };