@jjlabsio/claude-crew 0.1.31 → 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,424 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Derived from @openai/codex-plugin-cc and modified for claude-crew.
3
+ import { createHash } from "node:crypto";
4
+ import fs from "node:fs";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+
8
+ import { resolveWorkspaceRoot } from "./workspace.mjs";
9
+
10
+ const STATE_VERSION = 1;
11
+ const PLUGIN_DATA_ENV = "CLAUDE_PLUGIN_DATA";
12
+ const FALLBACK_STATE_ROOT_DIR = path.join(os.tmpdir(), "crew-codex-companion");
13
+ const STATE_FILE_NAME = "state.json";
14
+ const JOBS_DIR_NAME = "jobs";
15
+ const LOCK_DIR_NAME = ".lock";
16
+ const LOCK_WAIT_TIMEOUT_MS = 10000;
17
+ const LOCK_POLL_INTERVAL_MS = 50;
18
+ const LOCK_STALE_AFTER_MS = 60000;
19
+ const MAX_JOBS = 50;
20
+
21
+ function nowIso() {
22
+ return new Date().toISOString();
23
+ }
24
+
25
+ function defaultState() {
26
+ return {
27
+ version: STATE_VERSION,
28
+ config: {
29
+ stopReviewGate: false
30
+ },
31
+ jobs: []
32
+ };
33
+ }
34
+
35
+ export function resolveStateDir(cwd) {
36
+ const workspaceRoot = resolveWorkspaceRoot(cwd);
37
+ let canonicalWorkspaceRoot = workspaceRoot;
38
+ try {
39
+ canonicalWorkspaceRoot = fs.realpathSync.native(workspaceRoot);
40
+ } catch {
41
+ canonicalWorkspaceRoot = workspaceRoot;
42
+ }
43
+
44
+ const slugSource = path.basename(workspaceRoot) || "workspace";
45
+ const slug = slugSource.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "workspace";
46
+ const hash = createHash("sha256").update(canonicalWorkspaceRoot).digest("hex").slice(0, 16);
47
+ const pluginDataDir = process.env[PLUGIN_DATA_ENV];
48
+ const stateRoot = pluginDataDir ? path.join(pluginDataDir, "state") : FALLBACK_STATE_ROOT_DIR;
49
+ return path.join(stateRoot, `${slug}-${hash}`);
50
+ }
51
+
52
+ export function resolveStateFile(cwd) {
53
+ return path.join(resolveStateDir(cwd), STATE_FILE_NAME);
54
+ }
55
+
56
+ export function resolveJobsDir(cwd) {
57
+ return path.join(resolveStateDir(cwd), JOBS_DIR_NAME);
58
+ }
59
+
60
+ export function ensureStateDir(cwd) {
61
+ fs.mkdirSync(resolveJobsDir(cwd), { recursive: true });
62
+ }
63
+
64
+ function sleepSync(ms) {
65
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
66
+ }
67
+
68
+ function sleep(ms) {
69
+ return new Promise((resolve) => setTimeout(resolve, ms));
70
+ }
71
+
72
+ function resolveLockDir(cwd) {
73
+ return path.join(resolveStateDir(cwd), LOCK_DIR_NAME);
74
+ }
75
+
76
+ function writeLockOwner(lockDir) {
77
+ fs.writeFileSync(
78
+ path.join(lockDir, "owner.json"),
79
+ `${JSON.stringify({ pid: process.pid, createdAt: nowIso() }, null, 2)}\n`,
80
+ "utf8"
81
+ );
82
+ }
83
+
84
+ function readLockOwner(lockDir) {
85
+ try {
86
+ return JSON.parse(fs.readFileSync(path.join(lockDir, "owner.json"), "utf8"));
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ function isProcessAlive(pid) {
93
+ if (!Number.isInteger(pid) || pid <= 0) {
94
+ return false;
95
+ }
96
+ try {
97
+ process.kill(pid, 0);
98
+ return true;
99
+ } catch (error) {
100
+ return error?.code === "EPERM";
101
+ }
102
+ }
103
+
104
+ function getLockAgeMs(lockDir, owner) {
105
+ const createdAt = Date.parse(owner?.createdAt ?? "");
106
+ if (Number.isFinite(createdAt)) {
107
+ return Date.now() - createdAt;
108
+ }
109
+ try {
110
+ return Date.now() - fs.statSync(lockDir).mtimeMs;
111
+ } catch {
112
+ return 0;
113
+ }
114
+ }
115
+
116
+ function removeStaleLock(lockDir, owner) {
117
+ try {
118
+ fs.rmSync(lockDir, { recursive: true, force: true });
119
+ } catch (error) {
120
+ throw new Error(
121
+ `Failed to remove stale Codex companion state lock held by pid ${owner?.pid ?? "unknown"}: ${
122
+ error instanceof Error ? error.message : String(error)
123
+ }`
124
+ );
125
+ }
126
+ }
127
+
128
+ function maybeRemoveStaleLock(lockDir, options = {}) {
129
+ const staleAfterMs = options.staleAfterMs ?? LOCK_STALE_AFTER_MS;
130
+ const owner = readLockOwner(lockDir);
131
+ const ageMs = getLockAgeMs(lockDir, owner);
132
+
133
+ if (!owner) {
134
+ if (ageMs >= staleAfterMs) {
135
+ removeStaleLock(lockDir, owner);
136
+ return true;
137
+ }
138
+ return false;
139
+ }
140
+
141
+ if (!isProcessAlive(owner.pid) || ageMs >= staleAfterMs) {
142
+ removeStaleLock(lockDir, owner);
143
+ return true;
144
+ }
145
+ return false;
146
+ }
147
+
148
+ function acquireStateLock(cwd, options = {}) {
149
+ const timeoutMs = options.timeoutMs ?? LOCK_WAIT_TIMEOUT_MS;
150
+ const pollIntervalMs = options.pollIntervalMs ?? LOCK_POLL_INTERVAL_MS;
151
+ const stateDir = resolveStateDir(cwd);
152
+ const lockDir = resolveLockDir(cwd);
153
+ const startedAt = Date.now();
154
+ fs.mkdirSync(stateDir, { recursive: true });
155
+
156
+ while (true) {
157
+ try {
158
+ fs.mkdirSync(lockDir);
159
+ writeLockOwner(lockDir);
160
+ return lockDir;
161
+ } catch (error) {
162
+ if (error?.code !== "EEXIST") {
163
+ throw error;
164
+ }
165
+ if (Date.now() - startedAt >= timeoutMs) {
166
+ throw new Error(`Timed out waiting for Codex companion state lock: ${lockDir}`);
167
+ }
168
+ if (maybeRemoveStaleLock(lockDir, options)) {
169
+ continue;
170
+ }
171
+ sleepSync(pollIntervalMs);
172
+ }
173
+ }
174
+ }
175
+
176
+ async function acquireStateLockAsync(cwd, options = {}) {
177
+ const timeoutMs = options.timeoutMs ?? LOCK_WAIT_TIMEOUT_MS;
178
+ const pollIntervalMs = options.pollIntervalMs ?? LOCK_POLL_INTERVAL_MS;
179
+ const stateDir = resolveStateDir(cwd);
180
+ const lockDir = resolveLockDir(cwd);
181
+ const startedAt = Date.now();
182
+ fs.mkdirSync(stateDir, { recursive: true });
183
+
184
+ while (true) {
185
+ try {
186
+ fs.mkdirSync(lockDir);
187
+ writeLockOwner(lockDir);
188
+ return lockDir;
189
+ } catch (error) {
190
+ if (error?.code !== "EEXIST") {
191
+ throw error;
192
+ }
193
+ if (Date.now() - startedAt >= timeoutMs) {
194
+ throw new Error(`Timed out waiting for Codex companion state lock: ${lockDir}`);
195
+ }
196
+ if (maybeRemoveStaleLock(lockDir, options)) {
197
+ continue;
198
+ }
199
+ await sleep(pollIntervalMs);
200
+ }
201
+ }
202
+ }
203
+
204
+ function releaseStateLock(lockDir) {
205
+ try {
206
+ fs.rmSync(lockDir, { recursive: true, force: true });
207
+ } catch {
208
+ // Lock release is best-effort; callers should not fail after their write completed.
209
+ }
210
+ }
211
+
212
+ export function withStateLock(cwd, callback, options = {}) {
213
+ const lockDir = acquireStateLock(cwd, options);
214
+ try {
215
+ return callback();
216
+ } finally {
217
+ releaseStateLock(lockDir);
218
+ }
219
+ }
220
+
221
+ export async function withStateLockAsync(cwd, callback, options = {}) {
222
+ const lockDir = await acquireStateLockAsync(cwd, options);
223
+ try {
224
+ return await callback();
225
+ } finally {
226
+ releaseStateLock(lockDir);
227
+ }
228
+ }
229
+
230
+ export function loadState(cwd) {
231
+ const stateFile = resolveStateFile(cwd);
232
+ if (!fs.existsSync(stateFile)) {
233
+ return defaultState();
234
+ }
235
+
236
+ try {
237
+ const parsed = JSON.parse(fs.readFileSync(stateFile, "utf8"));
238
+ return {
239
+ ...defaultState(),
240
+ ...parsed,
241
+ config: {
242
+ ...defaultState().config,
243
+ ...(parsed.config ?? {})
244
+ },
245
+ jobs: Array.isArray(parsed.jobs) ? parsed.jobs : []
246
+ };
247
+ } catch {
248
+ return defaultState();
249
+ }
250
+ }
251
+
252
+ function pruneJobs(jobs) {
253
+ return [...jobs]
254
+ .sort((left, right) => String(right.updatedAt ?? "").localeCompare(String(left.updatedAt ?? "")))
255
+ .slice(0, MAX_JOBS);
256
+ }
257
+
258
+ function removeFileIfExists(filePath) {
259
+ if (filePath && fs.existsSync(filePath)) {
260
+ fs.unlinkSync(filePath);
261
+ }
262
+ }
263
+
264
+ export function writeJsonFileAtomic(filePath, payload) {
265
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
266
+ const tempPath = path.join(
267
+ path.dirname(filePath),
268
+ `.${path.basename(filePath)}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`
269
+ );
270
+ fs.writeFileSync(tempPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
271
+ fs.renameSync(tempPath, filePath);
272
+ }
273
+
274
+ function saveStateUnlocked(cwd, state) {
275
+ const previousJobs = loadState(cwd).jobs;
276
+ ensureStateDir(cwd);
277
+ const nextJobs = pruneJobs(state.jobs ?? []);
278
+ const nextState = {
279
+ version: STATE_VERSION,
280
+ config: {
281
+ ...defaultState().config,
282
+ ...(state.config ?? {})
283
+ },
284
+ jobs: nextJobs
285
+ };
286
+
287
+ const retainedIds = new Set(nextJobs.map((job) => job.id));
288
+ for (const job of previousJobs) {
289
+ if (retainedIds.has(job.id)) {
290
+ continue;
291
+ }
292
+ removeJobFile(resolveJobFile(cwd, job.id));
293
+ removeFileIfExists(job.logFile);
294
+ }
295
+
296
+ writeJsonFileAtomic(resolveStateFile(cwd), nextState);
297
+ return nextState;
298
+ }
299
+
300
+ export function saveState(cwd, state) {
301
+ return withStateLock(cwd, () => saveStateUnlocked(cwd, state));
302
+ }
303
+
304
+ export function updateState(cwd, mutate) {
305
+ return withStateLock(cwd, () => {
306
+ const state = loadState(cwd);
307
+ mutate(state);
308
+ return saveStateUnlocked(cwd, state);
309
+ });
310
+ }
311
+
312
+ export function generateJobId(prefix = "job") {
313
+ const random = Math.random().toString(36).slice(2, 8);
314
+ return `${prefix}-${Date.now().toString(36)}-${random}`;
315
+ }
316
+
317
+ export function upsertJob(cwd, jobPatch) {
318
+ return updateState(cwd, (state) => {
319
+ const timestamp = nowIso();
320
+ const existingIndex = state.jobs.findIndex((job) => job.id === jobPatch.id);
321
+ if (existingIndex === -1) {
322
+ state.jobs.unshift({
323
+ createdAt: timestamp,
324
+ updatedAt: timestamp,
325
+ ...jobPatch
326
+ });
327
+ return;
328
+ }
329
+ state.jobs[existingIndex] = {
330
+ ...state.jobs[existingIndex],
331
+ ...jobPatch,
332
+ updatedAt: timestamp
333
+ };
334
+ });
335
+ }
336
+
337
+ export function updateJobStateAndFile(cwd, jobId, mutate) {
338
+ return withStateLock(cwd, () => {
339
+ const state = loadState(cwd);
340
+ const timestamp = nowIso();
341
+ const existingIndex = state.jobs.findIndex((job) => job.id === jobId);
342
+ const existingJob = existingIndex === -1 ? null : state.jobs[existingIndex];
343
+ const result = mutate(existingJob, { timestamp });
344
+ if (!result) {
345
+ return null;
346
+ }
347
+
348
+ const stateJob = result.stateJob ?? result.job ?? null;
349
+ if (!stateJob) {
350
+ return null;
351
+ }
352
+
353
+ const nextStateJob = {
354
+ ...(existingJob ?? {
355
+ createdAt: timestamp
356
+ }),
357
+ ...stateJob,
358
+ id: jobId,
359
+ updatedAt: stateJob.updatedAt ?? timestamp
360
+ };
361
+
362
+ if (existingIndex === -1) {
363
+ state.jobs.unshift(nextStateJob);
364
+ } else {
365
+ state.jobs[existingIndex] = nextStateJob;
366
+ }
367
+
368
+ const nextState = saveStateUnlocked(cwd, state);
369
+ const fileJob = result.fileJob ?? nextStateJob;
370
+ if (fileJob) {
371
+ writeJsonFileAtomic(resolveJobFile(cwd, jobId), fileJob);
372
+ }
373
+
374
+ return {
375
+ state: nextState,
376
+ job: nextStateJob,
377
+ fileJob
378
+ };
379
+ });
380
+ }
381
+
382
+ export function listJobs(cwd) {
383
+ return loadState(cwd).jobs;
384
+ }
385
+
386
+ export function setConfig(cwd, key, value) {
387
+ return updateState(cwd, (state) => {
388
+ state.config = {
389
+ ...state.config,
390
+ [key]: value
391
+ };
392
+ });
393
+ }
394
+
395
+ export function getConfig(cwd) {
396
+ return loadState(cwd).config;
397
+ }
398
+
399
+ export function writeJobFile(cwd, jobId, payload) {
400
+ ensureStateDir(cwd);
401
+ const jobFile = resolveJobFile(cwd, jobId);
402
+ writeJsonFileAtomic(jobFile, payload);
403
+ return jobFile;
404
+ }
405
+
406
+ export function readJobFile(jobFile) {
407
+ return JSON.parse(fs.readFileSync(jobFile, "utf8"));
408
+ }
409
+
410
+ function removeJobFile(jobFile) {
411
+ if (fs.existsSync(jobFile)) {
412
+ fs.unlinkSync(jobFile);
413
+ }
414
+ }
415
+
416
+ export function resolveJobLogFile(cwd, jobId) {
417
+ ensureStateDir(cwd);
418
+ return path.join(resolveJobsDir(cwd), `${jobId}.log`);
419
+ }
420
+
421
+ export function resolveJobFile(cwd, jobId) {
422
+ ensureStateDir(cwd);
423
+ return path.join(resolveJobsDir(cwd), `${jobId}.json`);
424
+ }
@@ -0,0 +1,279 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Derived from @openai/codex-plugin-cc and modified for claude-crew.
3
+ import fs from "node:fs";
4
+ import process from "node:process";
5
+
6
+ import { readJobFile, resolveJobFile, resolveJobLogFile, updateJobStateAndFile } from "./state.mjs";
7
+
8
+ export const SESSION_ID_ENV = "CODEX_COMPANION_SESSION_ID";
9
+
10
+ export function nowIso() {
11
+ return new Date().toISOString();
12
+ }
13
+
14
+ function normalizeProgressEvent(value) {
15
+ if (value && typeof value === "object" && !Array.isArray(value)) {
16
+ return {
17
+ message: String(value.message ?? "").trim(),
18
+ phase: typeof value.phase === "string" && value.phase.trim() ? value.phase.trim() : null,
19
+ threadId: typeof value.threadId === "string" && value.threadId.trim() ? value.threadId.trim() : null,
20
+ turnId: typeof value.turnId === "string" && value.turnId.trim() ? value.turnId.trim() : null,
21
+ stderrMessage: value.stderrMessage == null ? null : String(value.stderrMessage).trim(),
22
+ logTitle: typeof value.logTitle === "string" && value.logTitle.trim() ? value.logTitle.trim() : null,
23
+ logBody: value.logBody == null ? null : String(value.logBody).trimEnd()
24
+ };
25
+ }
26
+
27
+ return {
28
+ message: String(value ?? "").trim(),
29
+ phase: null,
30
+ threadId: null,
31
+ turnId: null,
32
+ stderrMessage: String(value ?? "").trim(),
33
+ logTitle: null,
34
+ logBody: null
35
+ };
36
+ }
37
+
38
+ export function appendLogLine(logFile, message) {
39
+ const normalized = String(message ?? "").trim();
40
+ if (!logFile || !normalized) {
41
+ return;
42
+ }
43
+ fs.appendFileSync(logFile, `[${nowIso()}] ${normalized}\n`, "utf8");
44
+ }
45
+
46
+ export function appendLogBlock(logFile, title, body) {
47
+ if (!logFile || !body) {
48
+ return;
49
+ }
50
+ fs.appendFileSync(logFile, `\n[${nowIso()}] ${title}\n${String(body).trimEnd()}\n`, "utf8");
51
+ }
52
+
53
+ export function createJobLogFile(workspaceRoot, jobId, title) {
54
+ const logFile = resolveJobLogFile(workspaceRoot, jobId);
55
+ fs.writeFileSync(logFile, "", "utf8");
56
+ if (title) {
57
+ appendLogLine(logFile, `Starting ${title}.`);
58
+ }
59
+ return logFile;
60
+ }
61
+
62
+ export function createJobRecord(base, options = {}) {
63
+ const env = options.env ?? process.env;
64
+ const sessionId = env[options.sessionIdEnv ?? SESSION_ID_ENV];
65
+ return {
66
+ ...base,
67
+ createdAt: nowIso(),
68
+ ...(sessionId ? { sessionId } : {})
69
+ };
70
+ }
71
+
72
+ export function createJobProgressUpdater(workspaceRoot, jobId) {
73
+ let lastPhase = null;
74
+ let lastThreadId = null;
75
+ let lastTurnId = null;
76
+
77
+ return (event) => {
78
+ const normalized = normalizeProgressEvent(event);
79
+ const patch = { id: jobId };
80
+ let changed = false;
81
+
82
+ if (normalized.phase && normalized.phase !== lastPhase) {
83
+ lastPhase = normalized.phase;
84
+ patch.phase = normalized.phase;
85
+ changed = true;
86
+ }
87
+
88
+ if (normalized.threadId && normalized.threadId !== lastThreadId) {
89
+ lastThreadId = normalized.threadId;
90
+ patch.threadId = normalized.threadId;
91
+ changed = true;
92
+ }
93
+
94
+ if (normalized.turnId && normalized.turnId !== lastTurnId) {
95
+ lastTurnId = normalized.turnId;
96
+ patch.turnId = normalized.turnId;
97
+ changed = true;
98
+ }
99
+
100
+ if (!changed) {
101
+ return;
102
+ }
103
+
104
+ updateJobStateAndFile(workspaceRoot, jobId, (existing) => {
105
+ if (!existing || existing.status === "completed" || existing.status === "failed" || existing.status === "cancelled") {
106
+ return null;
107
+ }
108
+
109
+ const jobFile = resolveJobFile(workspaceRoot, jobId);
110
+ const storedJob = fs.existsSync(jobFile) ? readJobFile(jobFile) : existing;
111
+ return {
112
+ stateJob: {
113
+ ...patch,
114
+ phase: patch.phase ?? existing.phase
115
+ },
116
+ fileJob: {
117
+ ...storedJob,
118
+ ...patch,
119
+ phase: patch.phase ?? storedJob.phase
120
+ }
121
+ };
122
+ });
123
+ };
124
+ }
125
+
126
+ export function createProgressReporter({ stderr = false, logFile = null, onEvent = null } = {}) {
127
+ if (!stderr && !logFile && !onEvent) {
128
+ return null;
129
+ }
130
+
131
+ return (eventOrMessage) => {
132
+ const event = normalizeProgressEvent(eventOrMessage);
133
+ const stderrMessage = event.stderrMessage ?? event.message;
134
+ if (stderr && stderrMessage) {
135
+ process.stderr.write(`[codex] ${stderrMessage}\n`);
136
+ }
137
+ appendLogLine(logFile, event.message);
138
+ appendLogBlock(logFile, event.logTitle, event.logBody);
139
+ onEvent?.(event);
140
+ };
141
+ }
142
+
143
+ function readStoredJobOrNull(workspaceRoot, jobId) {
144
+ const jobFile = resolveJobFile(workspaceRoot, jobId);
145
+ if (!fs.existsSync(jobFile)) {
146
+ return null;
147
+ }
148
+ return readJobFile(jobFile);
149
+ }
150
+
151
+ function updateActiveRunningJob(workspaceRoot, jobId, pid, patch, fileJob = null) {
152
+ const updated = updateJobStateAndFile(workspaceRoot, jobId, (existing) => {
153
+ if (!existing || existing.status !== "running" || existing.pid !== pid) {
154
+ return null;
155
+ }
156
+
157
+ return {
158
+ stateJob: patch,
159
+ ...(fileJob ? { fileJob } : {})
160
+ };
161
+ });
162
+ return Boolean(updated);
163
+ }
164
+
165
+ function markJobRunning(workspaceRoot, runningRecord) {
166
+ const updated = updateJobStateAndFile(workspaceRoot, runningRecord.id, (existing, { timestamp }) => {
167
+ if (existing && existing.status !== "queued") {
168
+ return null;
169
+ }
170
+
171
+ return {
172
+ stateJob: {
173
+ ...runningRecord,
174
+ updatedAt: timestamp
175
+ },
176
+ fileJob: runningRecord
177
+ };
178
+ });
179
+ return Boolean(updated);
180
+ }
181
+
182
+ export async function runTrackedJob(job, runner, options = {}) {
183
+ const runningRecord = {
184
+ ...job,
185
+ status: "running",
186
+ startedAt: nowIso(),
187
+ phase: "starting",
188
+ pid: process.pid,
189
+ logFile: options.logFile ?? job.logFile ?? null
190
+ };
191
+ const started = markJobRunning(job.workspaceRoot, runningRecord);
192
+ if (!started) {
193
+ appendLogLine(options.logFile ?? job.logFile ?? null, "Job was no longer queued; skipping worker execution.");
194
+ return {
195
+ exitStatus: 1,
196
+ threadId: job.threadId ?? null,
197
+ turnId: job.turnId ?? null,
198
+ payload: {
199
+ status: 1,
200
+ errorMessage: "Job was no longer queued; skipping worker execution."
201
+ },
202
+ rendered: "Job was no longer queued; skipping worker execution.\n",
203
+ summary: "Job skipped because it was no longer queued."
204
+ };
205
+ }
206
+
207
+ try {
208
+ const execution = await runner();
209
+ const completionStatus = execution.exitStatus === 0 ? "completed" : "failed";
210
+ const completedAt = nowIso();
211
+ const completionPatch = {
212
+ status: completionStatus,
213
+ threadId: execution.threadId ?? null,
214
+ turnId: execution.turnId ?? null,
215
+ summary: execution.summary,
216
+ phase: completionStatus === "completed" ? "done" : "failed",
217
+ pid: null,
218
+ completedAt,
219
+ updatedAt: completedAt
220
+ };
221
+ const completedFileJob = {
222
+ ...runningRecord,
223
+ status: completionStatus,
224
+ threadId: execution.threadId ?? null,
225
+ turnId: execution.turnId ?? null,
226
+ pid: null,
227
+ phase: completionStatus === "completed" ? "done" : "failed",
228
+ completedAt,
229
+ result: execution.payload,
230
+ rendered: execution.rendered
231
+ };
232
+ const transitioned = updateActiveRunningJob(
233
+ job.workspaceRoot,
234
+ job.id,
235
+ process.pid,
236
+ completionPatch,
237
+ completedFileJob
238
+ );
239
+ if (!transitioned) {
240
+ appendLogLine(options.logFile ?? job.logFile ?? null, "Job was no longer active at completion; preserving current job state.");
241
+ return execution;
242
+ }
243
+ appendLogBlock(options.logFile ?? job.logFile ?? null, "Final output", execution.rendered);
244
+ return execution;
245
+ } catch (error) {
246
+ const errorMessage = error instanceof Error ? error.message : String(error);
247
+ const existing = readStoredJobOrNull(job.workspaceRoot, job.id) ?? runningRecord;
248
+ const completedAt = nowIso();
249
+ const failurePatch = {
250
+ status: "failed",
251
+ phase: "failed",
252
+ pid: null,
253
+ errorMessage,
254
+ completedAt,
255
+ updatedAt: completedAt
256
+ };
257
+ const failedFileJob = {
258
+ ...existing,
259
+ status: "failed",
260
+ phase: "failed",
261
+ errorMessage,
262
+ pid: null,
263
+ completedAt,
264
+ logFile: options.logFile ?? job.logFile ?? existing.logFile ?? null
265
+ };
266
+ const transitioned = updateActiveRunningJob(
267
+ job.workspaceRoot,
268
+ job.id,
269
+ process.pid,
270
+ failurePatch,
271
+ failedFileJob
272
+ );
273
+ if (!transitioned) {
274
+ appendLogLine(options.logFile ?? job.logFile ?? null, "Job was no longer active after failure; preserving current job state.");
275
+ throw error;
276
+ }
277
+ throw error;
278
+ }
279
+ }
@@ -0,0 +1,11 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Derived from @openai/codex-plugin-cc and modified for claude-crew.
3
+ import { ensureGitRepository } from "./git.mjs";
4
+
5
+ export function resolveWorkspaceRoot(cwd) {
6
+ try {
7
+ return ensureGitRepository(cwd);
8
+ } catch {
9
+ return cwd;
10
+ }
11
+ }