ninja-terminals 2.3.1 → 2.3.3

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,101 @@
1
+ /**
2
+ * VeryRealNames - Absurd but plausible name generator
3
+ * Names sound like they COULD be real... but aren't
4
+ */
5
+
6
+ const FIRST_NAMES = [
7
+ // Old-timey gems
8
+ 'Hank', 'Gertrude', 'Chester', 'Mildred', 'Barnaby', 'Ethel', 'Cornelius', 'Bertha',
9
+ 'Thaddeus', 'Gladys', 'Reginald', 'Edna', 'Archibald', 'Myrtle', 'Beauregard', 'Agatha',
10
+ 'Wilbur', 'Prudence', 'Mortimer', 'Hortense', 'Bartholomew', 'Eunice', 'Horatio', 'Blanche',
11
+ 'Cuthbert', 'Delphine', 'Engelbert', 'Geraldine', 'Humphrey', 'Imogene',
12
+ // Slightly unusual but real
13
+ 'Grover', 'Cletus', 'Melvin', 'Dorcas', 'Floyd', 'Beulah', 'Lester', 'Opal',
14
+ 'Merle', 'Velma', 'Elmer', 'Lurleen', 'Durwood', 'Wanda', 'Gus', 'Bernice',
15
+ 'Norbert', 'Doreen', 'Seymour', 'Fern', 'Murray', 'Irma', 'Roscoe', 'Hazel',
16
+ // Fancy yet awkward
17
+ 'Percival', 'Millicent', 'Fitzgerald', 'Clementine', 'Montgomery', 'Josephine',
18
+ 'Wellington', 'Cordelia', 'Pemberton', 'Henrietta', 'Thurgood', 'Philomena',
19
+ ];
20
+
21
+ const LAST_NAMES = [
22
+ // Food-adjacent
23
+ 'Pancely', 'Biscuitson', 'Crumbsworth', 'Gravyton', 'Stewbottom', 'Butterham',
24
+ 'Cheesley', 'Meatworth', 'Croutonski', 'Souperton', 'Hamsworth', 'Loafman',
25
+ 'Brisketson', 'Cabbagely', 'Noodleman', 'Porridge', 'Beefington', 'Dumpling',
26
+ // Animal-infused
27
+ 'Wombleton', 'Badgerly', 'Gooseman', 'Ducksworth', 'Weaselton', 'Moosebury',
28
+ 'Ferretson', 'Hedgewood', 'Squirrelman', 'Toadsworth', 'Pigglesworth', 'Newterson',
29
+ 'Crabshaw', 'Lobsterman', 'Clamsworthy', 'Oysterton',
30
+ // Body part blends
31
+ 'Finklebottom', 'Rumplesworth', 'Kneely', 'Elbowton', 'Thumbleton', 'Noseberg',
32
+ 'Earlington', 'Toesworth', 'Chinley', 'Knuckleton', 'Shoulderby', 'Ankleman',
33
+ // Silly suffixes
34
+ 'Wobbleford', 'Snickerton', 'Puddlesworth', 'Bumblesby', 'Giggleston', 'Fumbleby',
35
+ 'Tumblewood', 'Mumbleston', 'Bumbleworth', 'Crumbleford', 'Stumblebrook',
36
+ // Object-based
37
+ 'Lampsworth', 'Chairington', 'Tableson', 'Doorknobby', 'Carpetman', 'Curtainsby',
38
+ 'Bucketworth', 'Brickleton', 'Plungerby', 'Sockington',
39
+ ];
40
+
41
+ export interface GeneratedName {
42
+ firstName: string;
43
+ lastName: string;
44
+ fullName: string;
45
+ }
46
+
47
+ /**
48
+ * Generate a single absurd-but-plausible name
49
+ */
50
+ export function generateName(): GeneratedName {
51
+ const firstName = FIRST_NAMES[Math.floor(Math.random() * FIRST_NAMES.length)];
52
+ const lastName = LAST_NAMES[Math.floor(Math.random() * LAST_NAMES.length)];
53
+
54
+ return {
55
+ firstName,
56
+ lastName,
57
+ fullName: `${firstName} ${lastName}`,
58
+ };
59
+ }
60
+
61
+ /**
62
+ * Generate multiple unique names
63
+ */
64
+ export function generateNames(count: number): GeneratedName[] {
65
+ const names: GeneratedName[] = [];
66
+ const seen = new Set<string>();
67
+
68
+ const maxPossible = FIRST_NAMES.length * LAST_NAMES.length;
69
+ const targetCount = Math.min(count, maxPossible);
70
+
71
+ while (names.length < targetCount) {
72
+ const name = generateName();
73
+ if (!seen.has(name.fullName)) {
74
+ seen.add(name.fullName);
75
+ names.push(name);
76
+ }
77
+ }
78
+
79
+ return names;
80
+ }
81
+
82
+ /**
83
+ * Get all available first names
84
+ */
85
+ export function getFirstNames(): readonly string[] {
86
+ return FIRST_NAMES;
87
+ }
88
+
89
+ /**
90
+ * Get all available last names
91
+ */
92
+ export function getLastNames(): readonly string[] {
93
+ return LAST_NAMES;
94
+ }
95
+
96
+ /**
97
+ * Get total possible unique combinations
98
+ */
99
+ export function getTotalCombinations(): number {
100
+ return FIRST_NAMES.length * LAST_NAMES.length;
101
+ }
@@ -41,11 +41,13 @@ async function loadToolRatings() {
41
41
 
42
42
  /**
43
43
  * Generate tool guidance strings from ratings.
44
+ * Limited to top 5 warnings + top 5 recommendations to avoid flooding terminals.
44
45
  * @param {Map<string, object>} ratings
45
46
  * @returns {string[]}
46
47
  */
47
48
  function generateToolGuidance(ratings) {
48
- const guidance = [];
49
+ const warnings = [];
50
+ const recommendations = [];
49
51
 
50
52
  for (const [tool, stats] of ratings) {
51
53
  const { rating, composite, success_rate } = stats;
@@ -54,16 +56,24 @@ function generateToolGuidance(ratings) {
54
56
  // Low-rated tool: suggest avoidance
55
57
  const alt = TOOL_ALTERNATIVES[tool];
56
58
  if (alt) {
57
- guidance.push(`Avoid ${tool} for ${alt.useCase}, prefer ${alt.preferred} (${tool} has ${rating} rating: ${composite})`);
59
+ warnings.push({ tool, msg: `Avoid ${tool} for ${alt.useCase}, prefer ${alt.preferred} (${rating} rating)`, composite });
58
60
  } else {
59
- guidance.push(`Use ${tool} cautiously — ${rating} rating (${composite}), success rate: ${(success_rate * 100).toFixed(0)}%`);
61
+ warnings.push({ tool, msg: `Use ${tool} cautiously — ${rating} rating, success: ${(success_rate * 100).toFixed(0)}%`, composite });
60
62
  }
61
63
  } else if (rating === 'S' || rating === 'A') {
62
64
  // High-rated tool: recommend preference
63
- guidance.push(`Prefer ${tool} — reliable (${rating} rating: ${composite})`);
65
+ recommendations.push({ tool, msg: `Prefer ${tool} — ${rating} rating`, composite });
64
66
  }
65
67
  }
66
68
 
69
+ // Sort and limit: worst 5 warnings, best 5 recommendations
70
+ warnings.sort((a, b) => a.composite - b.composite);
71
+ recommendations.sort((a, b) => b.composite - a.composite);
72
+
73
+ const guidance = [];
74
+ for (const w of warnings.slice(0, 5)) guidance.push(w.msg);
75
+ for (const r of recommendations.slice(0, 5)) guidance.push(r.msg);
76
+
67
77
  return guidance;
68
78
  }
69
79
 
@@ -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
  };