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
|
@@ -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
|
+
};
|
package/lib/status-detect.js
CHANGED
|
@@ -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 => /^[>❯]
|
|
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 (
|
|
78
|
-
if (
|
|
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
|
};
|