@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +2 -1
- package/README.md +11 -1
- package/THIRD_PARTY_NOTICES.md +14 -0
- package/data/provider-catalog.json +36 -14
- package/package.json +2 -1
- package/scripts/crew-codex/LICENSE +201 -0
- package/scripts/crew-codex/NOTICE +16 -0
- package/scripts/crew-codex/app-server-broker.mjs +254 -0
- package/scripts/crew-codex/lib/app-server.mjs +352 -0
- package/scripts/crew-codex/lib/args.mjs +130 -0
- package/scripts/crew-codex/lib/broker-endpoint.mjs +43 -0
- package/scripts/crew-codex/lib/broker-lifecycle.mjs +213 -0
- package/scripts/crew-codex/lib/codex.mjs +1090 -0
- package/scripts/crew-codex/lib/fs.mjs +42 -0
- package/scripts/crew-codex/lib/git.mjs +348 -0
- package/scripts/crew-codex/lib/job-control.mjs +310 -0
- package/scripts/crew-codex/lib/process.mjs +137 -0
- package/scripts/crew-codex/lib/prompts.mjs +15 -0
- package/scripts/crew-codex/lib/render.mjs +466 -0
- package/scripts/crew-codex/lib/state.mjs +424 -0
- package/scripts/crew-codex/lib/tracked-jobs.mjs +279 -0
- package/scripts/crew-codex/lib/workspace.mjs +11 -0
- package/scripts/crew-codex-companion.mjs +863 -0
- package/skills/crew-dev/SKILL.md +65 -15
- package/skills/crew-interview/SKILL.md +34 -6
- package/skills/crew-plan/SKILL.md +43 -15
- package/skills/crew-setup/SKILL.md +38 -34
|
@@ -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
|
+
}
|