@jjlabsio/claude-crew 0.1.30 → 0.1.32

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,863 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ // Derived from @openai/codex-plugin-cc and modified for claude-crew.
4
+
5
+ import { spawn } from "node:child_process";
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ import process from "node:process";
9
+ import { fileURLToPath } from "node:url";
10
+
11
+ import { parseArgs, splitRawArgumentString } from "./crew-codex/lib/args.mjs";
12
+ import {
13
+ buildPersistentTaskThreadName,
14
+ DEFAULT_CONTINUE_PROMPT,
15
+ findLatestTaskThread,
16
+ getCodexAvailability,
17
+ interruptAppServerTurn,
18
+ runAppServerTurn
19
+ } from "./crew-codex/lib/codex.mjs";
20
+ import { readStdinIfPiped } from "./crew-codex/lib/fs.mjs";
21
+ import { terminateProcessTree } from "./crew-codex/lib/process.mjs";
22
+ import {
23
+ generateJobId,
24
+ listJobs,
25
+ updateJobStateAndFile,
26
+ updateState,
27
+ upsertJob,
28
+ writeJobFile
29
+ } from "./crew-codex/lib/state.mjs";
30
+ import {
31
+ buildSingleJobSnapshot,
32
+ buildStatusSnapshot,
33
+ readStoredJob,
34
+ resolveCancelableJob,
35
+ resolveResultJob,
36
+ sortJobsNewestFirst
37
+ } from "./crew-codex/lib/job-control.mjs";
38
+ import {
39
+ appendLogLine,
40
+ createJobLogFile,
41
+ createJobProgressUpdater,
42
+ createJobRecord,
43
+ createProgressReporter,
44
+ nowIso,
45
+ runTrackedJob,
46
+ SESSION_ID_ENV
47
+ } from "./crew-codex/lib/tracked-jobs.mjs";
48
+ import { resolveWorkspaceRoot } from "./crew-codex/lib/workspace.mjs";
49
+ import {
50
+ renderStoredJobResult,
51
+ renderCancelReport,
52
+ renderJobStatusReport,
53
+ renderStatusReport,
54
+ renderTaskResult
55
+ } from "./crew-codex/lib/render.mjs";
56
+
57
+ const ROOT_DIR = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
58
+ const DEFAULT_STATUS_WAIT_TIMEOUT_MS = 240000;
59
+ const DEFAULT_STATUS_POLL_INTERVAL_MS = 2000;
60
+ const VALID_REASONING_EFFORTS = new Set(["none", "minimal", "low", "medium", "high", "xhigh"]);
61
+ const MODEL_ALIASES = new Map([["spark", "gpt-5.3-codex-spark"]]);
62
+ const STOP_REVIEW_TASK_MARKER = "Run a stop-gate review of the previous Claude turn.";
63
+ const CREW_AGENT_RESULT_PATTERN = /<crew-agent-result>\s*([\s\S]*?)\s*<\/crew-agent-result>/g;
64
+ const CREW_AGENT_RESULT_STATUSES = new Set([
65
+ "complete",
66
+ "blocked_on_user",
67
+ "needs_agent",
68
+ "needs_tool",
69
+ "failed"
70
+ ]);
71
+
72
+ function printUsage() {
73
+ console.log(
74
+ [
75
+ "Usage:",
76
+ " node scripts/crew-codex-companion.mjs task [--background] [--write] [--expect-crew-result] [--resume-last|--resume|--fresh] [--model <model|spark>] [--effort <none|minimal|low|medium|high|xhigh>] [prompt]",
77
+ " node scripts/crew-codex-companion.mjs status [job-id] [--all] [--json]",
78
+ " node scripts/crew-codex-companion.mjs result [job-id] [--json]",
79
+ " node scripts/crew-codex-companion.mjs cancel [job-id] [--json]"
80
+ ].join("\n")
81
+ );
82
+ }
83
+
84
+ function outputResult(value, asJson) {
85
+ if (asJson) {
86
+ console.log(JSON.stringify(value, null, 2));
87
+ } else {
88
+ process.stdout.write(value);
89
+ }
90
+ }
91
+
92
+ function outputCommandResult(payload, rendered, asJson) {
93
+ outputResult(asJson ? payload : rendered, asJson);
94
+ }
95
+
96
+ function normalizeRequestedModel(model) {
97
+ if (model == null) {
98
+ return null;
99
+ }
100
+ const normalized = String(model).trim();
101
+ if (!normalized) {
102
+ return null;
103
+ }
104
+ return MODEL_ALIASES.get(normalized.toLowerCase()) ?? normalized;
105
+ }
106
+
107
+ function normalizeReasoningEffort(effort) {
108
+ if (effort == null) {
109
+ return null;
110
+ }
111
+ const normalized = String(effort).trim().toLowerCase();
112
+ if (!normalized) {
113
+ return null;
114
+ }
115
+ if (!VALID_REASONING_EFFORTS.has(normalized)) {
116
+ throw new Error(
117
+ `Unsupported reasoning effort "${effort}". Use one of: none, minimal, low, medium, high, xhigh.`
118
+ );
119
+ }
120
+ return normalized;
121
+ }
122
+
123
+ function normalizeArgv(argv) {
124
+ if (argv.length === 1) {
125
+ const [raw] = argv;
126
+ if (!raw || !raw.trim()) {
127
+ return [];
128
+ }
129
+ return splitRawArgumentString(raw);
130
+ }
131
+ return argv;
132
+ }
133
+
134
+ function parseCommandInput(argv, config = {}) {
135
+ return parseArgs(normalizeArgv(argv), {
136
+ ...config,
137
+ aliasMap: {
138
+ C: "cwd",
139
+ ...(config.aliasMap ?? {})
140
+ }
141
+ });
142
+ }
143
+
144
+ function resolveCommandCwd(options = {}) {
145
+ return options.cwd ? path.resolve(process.cwd(), options.cwd) : process.cwd();
146
+ }
147
+
148
+ function resolveCommandWorkspace(options = {}) {
149
+ return resolveWorkspaceRoot(resolveCommandCwd(options));
150
+ }
151
+
152
+ function sleep(ms) {
153
+ return new Promise((resolve) => setTimeout(resolve, ms));
154
+ }
155
+
156
+ function shorten(text, limit = 96) {
157
+ const normalized = String(text ?? "").trim().replace(/\s+/g, " ");
158
+ if (!normalized) {
159
+ return "";
160
+ }
161
+ if (normalized.length <= limit) {
162
+ return normalized;
163
+ }
164
+ return `${normalized.slice(0, limit - 3)}...`;
165
+ }
166
+
167
+ function firstMeaningfulLine(text, fallback) {
168
+ const line = String(text ?? "")
169
+ .split(/\r?\n/)
170
+ .map((value) => value.trim())
171
+ .find(Boolean);
172
+ return line ?? fallback;
173
+ }
174
+
175
+ function validateCrewAgentResult(result) {
176
+ if (result == null || typeof result !== "object" || Array.isArray(result)) {
177
+ return "AgentResult must be a JSON object.";
178
+ }
179
+
180
+ if (!CREW_AGENT_RESULT_STATUSES.has(result.status)) {
181
+ return `AgentResult status must be one of: ${Array.from(CREW_AGENT_RESULT_STATUSES).join(", ")}.`;
182
+ }
183
+
184
+ if ("questions" in result && !Array.isArray(result.questions)) {
185
+ return "AgentResult questions must be an array when provided.";
186
+ }
187
+
188
+ if ("requests" in result && !Array.isArray(result.requests)) {
189
+ return "AgentResult requests must be an array when provided.";
190
+ }
191
+
192
+ if (result.status === "blocked_on_user" && (!Array.isArray(result.questions) || result.questions.length === 0)) {
193
+ return "AgentResult blocked_on_user requires a non-empty questions array.";
194
+ }
195
+
196
+ if (
197
+ (result.status === "needs_agent" || result.status === "needs_tool") &&
198
+ (!Array.isArray(result.requests) || result.requests.length === 0)
199
+ ) {
200
+ return `AgentResult ${result.status} requires a non-empty requests array.`;
201
+ }
202
+
203
+ return null;
204
+ }
205
+
206
+ function parseCrewAgentResult(rawOutput, required = false) {
207
+ const matches = [...String(rawOutput ?? "").matchAll(CREW_AGENT_RESULT_PATTERN)];
208
+ const latestMatch = matches.at(-1);
209
+ if (!latestMatch) {
210
+ return {
211
+ result: null,
212
+ error: required ? "Missing <crew-agent-result> block." : null
213
+ };
214
+ }
215
+
216
+ try {
217
+ const result = JSON.parse(latestMatch[1]);
218
+ const validationError = validateCrewAgentResult(result);
219
+ if (validationError) {
220
+ return {
221
+ result,
222
+ error: validationError
223
+ };
224
+ }
225
+
226
+ return {
227
+ result,
228
+ error: null
229
+ };
230
+ } catch (error) {
231
+ return {
232
+ result: null,
233
+ error: `Invalid <crew-agent-result> JSON: ${error instanceof Error ? error.message : String(error)}`
234
+ };
235
+ }
236
+ }
237
+
238
+ function ensureCodexAvailable(cwd) {
239
+ const availability = getCodexAvailability(cwd);
240
+ if (!availability.available) {
241
+ throw new Error("Codex CLI is not installed or is missing required runtime support. Install it with `npm install -g @openai/codex`, then rerun `/crew-setup`.");
242
+ }
243
+ }
244
+
245
+ function isActiveJobStatus(status) {
246
+ return status === "queued" || status === "running";
247
+ }
248
+
249
+ function getCurrentClaudeSessionId() {
250
+ return process.env[SESSION_ID_ENV] ?? null;
251
+ }
252
+
253
+ function filterJobsForCurrentClaudeSession(jobs) {
254
+ const sessionId = getCurrentClaudeSessionId();
255
+ if (!sessionId) {
256
+ return jobs;
257
+ }
258
+ return jobs.filter((job) => job.sessionId === sessionId);
259
+ }
260
+
261
+ function findLatestResumableTaskJob(jobs) {
262
+ return (
263
+ jobs.find(
264
+ (job) =>
265
+ job.jobClass === "task" &&
266
+ job.threadId &&
267
+ job.status !== "queued" &&
268
+ job.status !== "running"
269
+ ) ?? null
270
+ );
271
+ }
272
+
273
+ async function waitForSingleJobSnapshot(cwd, reference, options = {}) {
274
+ const timeoutMs = Math.max(0, Number(options.timeoutMs) || DEFAULT_STATUS_WAIT_TIMEOUT_MS);
275
+ const pollIntervalMs = Math.max(100, Number(options.pollIntervalMs) || DEFAULT_STATUS_POLL_INTERVAL_MS);
276
+ const deadline = Date.now() + timeoutMs;
277
+ let snapshot = buildSingleJobSnapshot(cwd, reference);
278
+
279
+ while (isActiveJobStatus(snapshot.job.status) && Date.now() < deadline) {
280
+ await sleep(Math.min(pollIntervalMs, Math.max(0, deadline - Date.now())));
281
+ snapshot = buildSingleJobSnapshot(cwd, reference);
282
+ }
283
+
284
+ return {
285
+ ...snapshot,
286
+ waitTimedOut: isActiveJobStatus(snapshot.job.status),
287
+ timeoutMs
288
+ };
289
+ }
290
+
291
+ async function resolveLatestTrackedTaskThread(cwd, options = {}) {
292
+ const workspaceRoot = resolveWorkspaceRoot(cwd);
293
+ const sessionId = getCurrentClaudeSessionId();
294
+ const jobs = sortJobsNewestFirst(listJobs(workspaceRoot)).filter((job) => job.id !== options.excludeJobId);
295
+ const visibleJobs = filterJobsForCurrentClaudeSession(jobs);
296
+ const activeTask = visibleJobs.find((job) => job.jobClass === "task" && (job.status === "queued" || job.status === "running"));
297
+ if (activeTask) {
298
+ throw new Error(`Task ${activeTask.id} is still running. Use crew-codex status before continuing it.`);
299
+ }
300
+
301
+ const trackedTask = findLatestResumableTaskJob(visibleJobs);
302
+ if (trackedTask) {
303
+ return { id: trackedTask.threadId };
304
+ }
305
+
306
+ if (sessionId) {
307
+ return null;
308
+ }
309
+
310
+ return findLatestTaskThread(workspaceRoot);
311
+ }
312
+
313
+ async function executeTaskRun(request) {
314
+ const workspaceRoot = resolveWorkspaceRoot(request.cwd);
315
+ ensureCodexAvailable(request.cwd);
316
+
317
+ const taskMetadata = buildTaskRunMetadata({
318
+ prompt: request.prompt,
319
+ resumeLast: request.resumeLast
320
+ });
321
+
322
+ let resumeThreadId = null;
323
+ if (request.resumeLast) {
324
+ const latestThread = await resolveLatestTrackedTaskThread(workspaceRoot, {
325
+ excludeJobId: request.jobId
326
+ });
327
+ if (!latestThread) {
328
+ throw new Error("No previous Codex task thread was found for this repository.");
329
+ }
330
+ resumeThreadId = latestThread.id;
331
+ }
332
+
333
+ if (!request.prompt && !resumeThreadId) {
334
+ throw new Error("Provide a prompt, a prompt file, piped stdin, or use --resume-last.");
335
+ }
336
+
337
+ const result = await runAppServerTurn(workspaceRoot, {
338
+ resumeThreadId,
339
+ prompt: request.prompt,
340
+ defaultPrompt: resumeThreadId ? DEFAULT_CONTINUE_PROMPT : "",
341
+ model: request.model,
342
+ effort: request.effort,
343
+ sandbox: request.write ? "workspace-write" : "read-only",
344
+ onProgress: request.onProgress,
345
+ persistThread: true,
346
+ threadName: resumeThreadId ? null : buildPersistentTaskThreadName(request.prompt || DEFAULT_CONTINUE_PROMPT)
347
+ });
348
+
349
+ const rawOutput = typeof result.finalMessage === "string" ? result.finalMessage : "";
350
+ const failureMessage = result.error?.message ?? result.stderr ?? "";
351
+ const rendered = renderTaskResult(
352
+ {
353
+ rawOutput,
354
+ failureMessage,
355
+ reasoningSummary: result.reasoningSummary
356
+ },
357
+ {
358
+ title: taskMetadata.title,
359
+ jobId: request.jobId ?? null,
360
+ write: Boolean(request.write)
361
+ }
362
+ );
363
+ const payload = {
364
+ status: result.status,
365
+ threadId: result.threadId,
366
+ rawOutput,
367
+ touchedFiles: result.touchedFiles,
368
+ reasoningSummary: result.reasoningSummary
369
+ };
370
+ if (request.expectCrewResult) {
371
+ const parsedCrewResult = parseCrewAgentResult(rawOutput, true);
372
+ payload.crewAgentResult = parsedCrewResult.result;
373
+ payload.crewAgentResultError = parsedCrewResult.error;
374
+ if (parsedCrewResult.error && result.status === 0) {
375
+ payload.status = 1;
376
+ } else if (parsedCrewResult.result?.status === "failed" && result.status === 0) {
377
+ payload.status = 1;
378
+ }
379
+ }
380
+
381
+ return {
382
+ exitStatus: payload.status,
383
+ threadId: result.threadId,
384
+ turnId: result.turnId,
385
+ payload,
386
+ rendered,
387
+ summary: firstMeaningfulLine(rawOutput, firstMeaningfulLine(failureMessage, `${taskMetadata.title} finished.`)),
388
+ jobTitle: taskMetadata.title,
389
+ jobClass: "task",
390
+ write: Boolean(request.write)
391
+ };
392
+ }
393
+
394
+ function buildTaskRunMetadata({ prompt, resumeLast = false }) {
395
+ if (!resumeLast && String(prompt ?? "").includes(STOP_REVIEW_TASK_MARKER)) {
396
+ return {
397
+ title: "Codex Stop Gate Review",
398
+ summary: "Stop-gate review of previous Claude turn"
399
+ };
400
+ }
401
+
402
+ const title = resumeLast ? "Codex Resume" : "Codex Task";
403
+ const fallbackSummary = resumeLast ? DEFAULT_CONTINUE_PROMPT : "Task";
404
+ return {
405
+ title,
406
+ summary: shorten(prompt || fallbackSummary)
407
+ };
408
+ }
409
+
410
+ function renderQueuedTaskLaunch(payload) {
411
+ return `${payload.title} started in the background as ${payload.jobId}. Check crew-codex status ${payload.jobId} for progress.\n`;
412
+ }
413
+
414
+ function getJobKindLabel(kind, jobClass) {
415
+ if (kind === "adversarial-review") {
416
+ return "adversarial-review";
417
+ }
418
+ return jobClass === "review" ? "review" : "task";
419
+ }
420
+
421
+ function createCompanionJob({ prefix, kind, title, workspaceRoot, jobClass, summary, write = false }) {
422
+ return createJobRecord({
423
+ id: generateJobId(prefix),
424
+ kind,
425
+ kindLabel: getJobKindLabel(kind, jobClass),
426
+ title,
427
+ workspaceRoot,
428
+ jobClass,
429
+ summary,
430
+ write
431
+ });
432
+ }
433
+
434
+ function createTrackedProgress(job, options = {}) {
435
+ const logFile = options.logFile ?? createJobLogFile(job.workspaceRoot, job.id, job.title);
436
+ return {
437
+ logFile,
438
+ progress: createProgressReporter({
439
+ stderr: Boolean(options.stderr),
440
+ logFile,
441
+ onEvent: createJobProgressUpdater(job.workspaceRoot, job.id)
442
+ })
443
+ };
444
+ }
445
+
446
+ function buildTaskJob(workspaceRoot, taskMetadata, write) {
447
+ return createCompanionJob({
448
+ prefix: "task",
449
+ kind: "task",
450
+ title: taskMetadata.title,
451
+ workspaceRoot,
452
+ jobClass: "task",
453
+ summary: taskMetadata.summary,
454
+ write
455
+ });
456
+ }
457
+
458
+ function buildTaskRequest({ cwd, model, effort, prompt, write, resumeLast, expectCrewResult, jobId }) {
459
+ return {
460
+ cwd,
461
+ model,
462
+ effort,
463
+ prompt,
464
+ write,
465
+ resumeLast,
466
+ expectCrewResult,
467
+ jobId
468
+ };
469
+ }
470
+
471
+ function readTaskPrompt(cwd, options, positionals) {
472
+ if (options["prompt-file"]) {
473
+ return fs.readFileSync(path.resolve(cwd, options["prompt-file"]), "utf8");
474
+ }
475
+
476
+ const positionalPrompt = positionals.join(" ");
477
+ return positionalPrompt || readStdinIfPiped();
478
+ }
479
+
480
+ function requireTaskRequest(prompt, resumeLast) {
481
+ if (!prompt && !resumeLast) {
482
+ throw new Error("Provide a prompt, a prompt file, piped stdin, or use --resume-last.");
483
+ }
484
+ }
485
+
486
+ async function runForegroundCommand(job, runner, options = {}) {
487
+ const { logFile, progress } = createTrackedProgress(job, {
488
+ logFile: options.logFile,
489
+ stderr: !options.json
490
+ });
491
+ const execution = await runTrackedJob(job, () => runner(progress), { logFile });
492
+ outputResult(options.json ? execution.payload : execution.rendered, options.json);
493
+ if (execution.exitStatus !== 0) {
494
+ process.exitCode = execution.exitStatus;
495
+ }
496
+ return execution;
497
+ }
498
+
499
+ function spawnDetachedTaskWorker(cwd, jobId) {
500
+ const scriptPath = path.join(ROOT_DIR, "scripts", "crew-codex-companion.mjs");
501
+ const child = spawn(process.execPath, [scriptPath, "task-worker", "--cwd", cwd, "--job-id", jobId], {
502
+ cwd,
503
+ env: process.env,
504
+ detached: true,
505
+ stdio: "ignore",
506
+ windowsHide: true
507
+ });
508
+ child.unref();
509
+ return child;
510
+ }
511
+
512
+ function attachQueuedWorkerPid(workspaceRoot, jobId, pid) {
513
+ if (!Number.isFinite(pid)) {
514
+ return;
515
+ }
516
+
517
+ updateState(workspaceRoot, (state) => {
518
+ const existingIndex = state.jobs.findIndex((candidate) => candidate.id === jobId);
519
+ if (existingIndex === -1 || state.jobs[existingIndex].status !== "queued") {
520
+ return;
521
+ }
522
+
523
+ state.jobs[existingIndex] = {
524
+ ...state.jobs[existingIndex],
525
+ pid
526
+ };
527
+ });
528
+ }
529
+
530
+ function markActiveJobCancelled(workspaceRoot, jobId, completedAt, existingFileJob = {}) {
531
+ const result = updateJobStateAndFile(workspaceRoot, jobId, (existing) => {
532
+ if (!existing) {
533
+ return null;
534
+ }
535
+ if (existing.status !== "queued" && existing.status !== "running") {
536
+ return null;
537
+ }
538
+
539
+ const nextJob = {
540
+ ...existing,
541
+ status: "cancelled",
542
+ phase: "cancelled",
543
+ pid: null,
544
+ completedAt,
545
+ updatedAt: completedAt,
546
+ errorMessage: "Cancelled by user."
547
+ };
548
+
549
+ return {
550
+ stateJob: nextJob,
551
+ fileJob: {
552
+ ...existingFileJob,
553
+ ...nextJob,
554
+ cancelledAt: completedAt
555
+ }
556
+ };
557
+ });
558
+ return result?.job ?? null;
559
+ }
560
+
561
+ function enqueueBackgroundTask(cwd, job, request) {
562
+ const { logFile } = createTrackedProgress(job);
563
+ appendLogLine(logFile, "Queued for background execution.");
564
+
565
+ const queuedRecord = {
566
+ ...job,
567
+ status: "queued",
568
+ phase: "queued",
569
+ pid: null,
570
+ logFile,
571
+ request
572
+ };
573
+ writeJobFile(job.workspaceRoot, job.id, queuedRecord);
574
+ upsertJob(job.workspaceRoot, queuedRecord);
575
+
576
+ const child = spawnDetachedTaskWorker(cwd, job.id);
577
+ attachQueuedWorkerPid(job.workspaceRoot, job.id, child.pid ?? null);
578
+
579
+ return {
580
+ payload: {
581
+ jobId: job.id,
582
+ status: "queued",
583
+ title: job.title,
584
+ summary: job.summary,
585
+ logFile
586
+ },
587
+ logFile
588
+ };
589
+ }
590
+
591
+ async function handleTask(argv) {
592
+ const { options, positionals } = parseCommandInput(argv, {
593
+ valueOptions: ["model", "effort", "cwd", "prompt-file"],
594
+ booleanOptions: ["json", "write", "expect-crew-result", "resume-last", "resume", "fresh", "background"],
595
+ aliasMap: {
596
+ m: "model"
597
+ }
598
+ });
599
+
600
+ const cwd = resolveCommandCwd(options);
601
+ const workspaceRoot = resolveCommandWorkspace(options);
602
+ const model = normalizeRequestedModel(options.model);
603
+ const effort = normalizeReasoningEffort(options.effort);
604
+ const prompt = readTaskPrompt(cwd, options, positionals);
605
+
606
+ const resumeLast = Boolean(options["resume-last"] || options.resume);
607
+ const fresh = Boolean(options.fresh);
608
+ if (resumeLast && fresh) {
609
+ throw new Error("Choose either --resume/--resume-last or --fresh.");
610
+ }
611
+ const write = Boolean(options.write);
612
+ const expectCrewResult = Boolean(options["expect-crew-result"]);
613
+ const taskMetadata = buildTaskRunMetadata({
614
+ prompt,
615
+ resumeLast
616
+ });
617
+
618
+ if (options.background) {
619
+ ensureCodexAvailable(cwd);
620
+ requireTaskRequest(prompt, resumeLast);
621
+
622
+ const job = buildTaskJob(workspaceRoot, taskMetadata, write);
623
+ const request = buildTaskRequest({
624
+ cwd,
625
+ model,
626
+ effort,
627
+ prompt,
628
+ write,
629
+ resumeLast,
630
+ expectCrewResult,
631
+ jobId: job.id
632
+ });
633
+ const { payload } = enqueueBackgroundTask(cwd, job, request);
634
+ outputCommandResult(payload, renderQueuedTaskLaunch(payload), options.json);
635
+ return;
636
+ }
637
+
638
+ const job = buildTaskJob(workspaceRoot, taskMetadata, write);
639
+ await runForegroundCommand(
640
+ job,
641
+ (progress) =>
642
+ executeTaskRun({
643
+ cwd,
644
+ model,
645
+ effort,
646
+ prompt,
647
+ write,
648
+ resumeLast,
649
+ expectCrewResult,
650
+ jobId: job.id,
651
+ onProgress: progress
652
+ }),
653
+ { json: options.json }
654
+ );
655
+ }
656
+
657
+ async function handleTaskWorker(argv) {
658
+ const { options } = parseCommandInput(argv, {
659
+ valueOptions: ["cwd", "job-id"]
660
+ });
661
+
662
+ if (!options["job-id"]) {
663
+ throw new Error("Missing required --job-id for task-worker.");
664
+ }
665
+
666
+ const cwd = resolveCommandCwd(options);
667
+ const workspaceRoot = resolveCommandWorkspace(options);
668
+ const storedJob = readStoredJob(workspaceRoot, options["job-id"]);
669
+ if (!storedJob) {
670
+ throw new Error(`No stored job found for ${options["job-id"]}.`);
671
+ }
672
+ if (storedJob.status !== "queued") {
673
+ appendLogLine(storedJob.logFile, `Skipping background worker because job is ${storedJob.status}.`);
674
+ return;
675
+ }
676
+
677
+ const request = storedJob.request;
678
+ if (!request || typeof request !== "object") {
679
+ throw new Error(`Stored job ${options["job-id"]} is missing its task request payload.`);
680
+ }
681
+
682
+ const { logFile, progress } = createTrackedProgress(
683
+ {
684
+ ...storedJob,
685
+ workspaceRoot
686
+ },
687
+ {
688
+ logFile: storedJob.logFile ?? null
689
+ }
690
+ );
691
+ await runTrackedJob(
692
+ {
693
+ ...storedJob,
694
+ workspaceRoot,
695
+ logFile
696
+ },
697
+ () =>
698
+ executeTaskRun({
699
+ ...request,
700
+ onProgress: progress
701
+ }),
702
+ { logFile }
703
+ );
704
+ }
705
+
706
+ async function handleStatus(argv) {
707
+ const { options, positionals } = parseCommandInput(argv, {
708
+ valueOptions: ["cwd", "timeout-ms", "poll-interval-ms"],
709
+ booleanOptions: ["json", "all", "wait"]
710
+ });
711
+
712
+ const cwd = resolveCommandCwd(options);
713
+ const reference = positionals[0] ?? "";
714
+ if (reference) {
715
+ const snapshot = options.wait
716
+ ? await waitForSingleJobSnapshot(cwd, reference, {
717
+ timeoutMs: options["timeout-ms"],
718
+ pollIntervalMs: options["poll-interval-ms"]
719
+ })
720
+ : buildSingleJobSnapshot(cwd, reference);
721
+ outputCommandResult(snapshot, renderJobStatusReport(snapshot.job), options.json);
722
+ return;
723
+ }
724
+
725
+ if (options.wait) {
726
+ throw new Error("`status --wait` requires a job id.");
727
+ }
728
+
729
+ const report = buildStatusSnapshot(cwd, { all: options.all });
730
+ outputResult(options.json ? report : renderStatusReport(report), options.json);
731
+ }
732
+
733
+ function handleResult(argv) {
734
+ const { options, positionals } = parseCommandInput(argv, {
735
+ valueOptions: ["cwd"],
736
+ booleanOptions: ["json"]
737
+ });
738
+
739
+ const cwd = resolveCommandCwd(options);
740
+ const reference = positionals[0] ?? "";
741
+ const { workspaceRoot, job } = resolveResultJob(cwd, reference);
742
+ const storedJob = readStoredJob(workspaceRoot, job.id);
743
+ const payload = {
744
+ job,
745
+ storedJob
746
+ };
747
+
748
+ outputCommandResult(payload, renderStoredJobResult(job, storedJob), options.json);
749
+ }
750
+
751
+ function handleTaskResumeCandidate(argv) {
752
+ const { options } = parseCommandInput(argv, {
753
+ valueOptions: ["cwd"],
754
+ booleanOptions: ["json"]
755
+ });
756
+
757
+ const cwd = resolveCommandCwd(options);
758
+ const workspaceRoot = resolveCommandWorkspace(options);
759
+ const sessionId = getCurrentClaudeSessionId();
760
+ const jobs = filterJobsForCurrentClaudeSession(sortJobsNewestFirst(listJobs(workspaceRoot)));
761
+ const candidate = findLatestResumableTaskJob(jobs);
762
+
763
+ const payload = {
764
+ available: Boolean(candidate),
765
+ sessionId,
766
+ candidate:
767
+ candidate == null
768
+ ? null
769
+ : {
770
+ id: candidate.id,
771
+ status: candidate.status,
772
+ title: candidate.title ?? null,
773
+ summary: candidate.summary ?? null,
774
+ threadId: candidate.threadId,
775
+ completedAt: candidate.completedAt ?? null,
776
+ updatedAt: candidate.updatedAt ?? null
777
+ }
778
+ };
779
+
780
+ const rendered = candidate
781
+ ? `Resumable task found: ${candidate.id} (${candidate.status}).\n`
782
+ : "No resumable task found for this session.\n";
783
+ outputCommandResult(payload, rendered, options.json);
784
+ }
785
+
786
+ async function handleCancel(argv) {
787
+ const { options, positionals } = parseCommandInput(argv, {
788
+ valueOptions: ["cwd"],
789
+ booleanOptions: ["json"]
790
+ });
791
+
792
+ const cwd = resolveCommandCwd(options);
793
+ const reference = positionals[0] ?? "";
794
+ const { workspaceRoot, job } = resolveCancelableJob(cwd, reference, { env: process.env });
795
+ const existing = readStoredJob(workspaceRoot, job.id) ?? {};
796
+ const threadId = existing.threadId ?? job.threadId ?? null;
797
+ const turnId = existing.turnId ?? job.turnId ?? null;
798
+ const completedAt = nowIso();
799
+ const nextJob = markActiveJobCancelled(workspaceRoot, job.id, completedAt, existing);
800
+ if (!nextJob) {
801
+ throw new Error(`Job ${job.id} is no longer active.`);
802
+ }
803
+
804
+ const interrupt = await interruptAppServerTurn(cwd, { threadId, turnId });
805
+ if (interrupt.attempted) {
806
+ appendLogLine(
807
+ job.logFile,
808
+ interrupt.interrupted
809
+ ? `Requested Codex turn interrupt for ${turnId} on ${threadId}.`
810
+ : `Codex turn interrupt failed${interrupt.detail ? `: ${interrupt.detail}` : "."}`
811
+ );
812
+ }
813
+
814
+ terminateProcessTree(job.pid ?? Number.NaN);
815
+ appendLogLine(job.logFile, "Cancelled by user.");
816
+
817
+ const payload = {
818
+ jobId: job.id,
819
+ status: "cancelled",
820
+ title: job.title,
821
+ turnInterruptAttempted: interrupt.attempted,
822
+ turnInterrupted: interrupt.interrupted
823
+ };
824
+
825
+ outputCommandResult(payload, renderCancelReport(nextJob), options.json);
826
+ }
827
+
828
+ async function main() {
829
+ const [subcommand, ...argv] = process.argv.slice(2);
830
+ if (!subcommand || subcommand === "help" || subcommand === "--help") {
831
+ printUsage();
832
+ return;
833
+ }
834
+
835
+ switch (subcommand) {
836
+ case "task":
837
+ await handleTask(argv);
838
+ break;
839
+ case "task-worker":
840
+ await handleTaskWorker(argv);
841
+ break;
842
+ case "status":
843
+ await handleStatus(argv);
844
+ break;
845
+ case "result":
846
+ handleResult(argv);
847
+ break;
848
+ case "task-resume-candidate":
849
+ handleTaskResumeCandidate(argv);
850
+ break;
851
+ case "cancel":
852
+ await handleCancel(argv);
853
+ break;
854
+ default:
855
+ throw new Error(`Unknown subcommand: ${subcommand}`);
856
+ }
857
+ }
858
+
859
+ main().catch((error) => {
860
+ const message = error instanceof Error ? error.message : String(error);
861
+ process.stderr.write(`${message}\n`);
862
+ process.exitCode = 1;
863
+ });