@zhang6hk/dsh-codex 0.0.0-stage → 0.1.0

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.
Files changed (37) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +18 -0
  3. package/README.md +75 -2
  4. package/README.zh.md +78 -0
  5. package/cordis.patch.yml +7 -0
  6. package/lib/args.js +118 -0
  7. package/lib/codex-path.js +31 -0
  8. package/lib/commands.js +242 -0
  9. package/lib/companion.js +177 -0
  10. package/lib/index.js +105 -0
  11. package/lib/render.js +114 -0
  12. package/lib/tool.js +103 -0
  13. package/locale/en.json +6 -0
  14. package/locale/zh.json +6 -0
  15. package/package.json +49 -4
  16. package/vendor/codex/.claude-plugin/plugin.json +8 -0
  17. package/vendor/codex/prompts/adversarial-review.md +84 -0
  18. package/vendor/codex/prompts/stop-review-gate.md +36 -0
  19. package/vendor/codex/schemas/review-output.schema.json +87 -0
  20. package/vendor/codex/scripts/app-server-broker.mjs +252 -0
  21. package/vendor/codex/scripts/codex-companion.mjs +1073 -0
  22. package/vendor/codex/scripts/lib/app-server-protocol.d.ts +75 -0
  23. package/vendor/codex/scripts/lib/app-server.mjs +354 -0
  24. package/vendor/codex/scripts/lib/args.mjs +128 -0
  25. package/vendor/codex/scripts/lib/broker-endpoint.mjs +41 -0
  26. package/vendor/codex/scripts/lib/broker-lifecycle.mjs +209 -0
  27. package/vendor/codex/scripts/lib/claude-session-transfer.mjs +44 -0
  28. package/vendor/codex/scripts/lib/codex.mjs +1219 -0
  29. package/vendor/codex/scripts/lib/fs.mjs +40 -0
  30. package/vendor/codex/scripts/lib/git.mjs +347 -0
  31. package/vendor/codex/scripts/lib/job-control.mjs +308 -0
  32. package/vendor/codex/scripts/lib/process.mjs +135 -0
  33. package/vendor/codex/scripts/lib/prompts.mjs +13 -0
  34. package/vendor/codex/scripts/lib/render.mjs +465 -0
  35. package/vendor/codex/scripts/lib/state.mjs +191 -0
  36. package/vendor/codex/scripts/lib/tracked-jobs.mjs +204 -0
  37. package/vendor/codex/scripts/lib/workspace.mjs +9 -0
@@ -0,0 +1,40 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ export function ensureAbsolutePath(cwd, maybePath) {
6
+ return path.isAbsolute(maybePath) ? maybePath : path.resolve(cwd, maybePath);
7
+ }
8
+
9
+ export function createTempDir(prefix = "codex-plugin-") {
10
+ return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
11
+ }
12
+
13
+ export function readJsonFile(filePath) {
14
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
15
+ }
16
+
17
+ export function writeJsonFile(filePath, value) {
18
+ fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
19
+ }
20
+
21
+ export function safeReadFile(filePath) {
22
+ return fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : "";
23
+ }
24
+
25
+ export function isProbablyText(buffer) {
26
+ const sample = buffer.subarray(0, Math.min(buffer.length, 4096));
27
+ for (const value of sample) {
28
+ if (value === 0) {
29
+ return false;
30
+ }
31
+ }
32
+ return true;
33
+ }
34
+
35
+ export function readStdinIfPiped() {
36
+ if (process.stdin.isTTY) {
37
+ return "";
38
+ }
39
+ return fs.readFileSync(0, "utf8");
40
+ }
@@ -0,0 +1,347 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import { isProbablyText } from "./fs.mjs";
5
+ import { formatCommandFailure, runCommand, runCommandChecked } from "./process.mjs";
6
+
7
+ const MAX_UNTRACKED_BYTES = 24 * 1024;
8
+ const DEFAULT_INLINE_DIFF_MAX_FILES = 2;
9
+ const DEFAULT_INLINE_DIFF_MAX_BYTES = 256 * 1024;
10
+
11
+ // Git is directly executable on Windows. Repository-derived arguments must never pass through a shell.
12
+ function git(cwd, args, options = {}) {
13
+ return runCommand("git", args, { cwd, ...options, shell: false });
14
+ }
15
+
16
+ function gitChecked(cwd, args, options = {}) {
17
+ return runCommandChecked("git", args, { cwd, ...options, shell: false });
18
+ }
19
+
20
+ function listUniqueFiles(...groups) {
21
+ return [...new Set(groups.flat().filter(Boolean))].sort();
22
+ }
23
+
24
+ function normalizeMaxInlineFiles(value) {
25
+ const parsed = Number(value);
26
+ if (!Number.isFinite(parsed) || parsed < 0) {
27
+ return DEFAULT_INLINE_DIFF_MAX_FILES;
28
+ }
29
+ return Math.floor(parsed);
30
+ }
31
+
32
+ function normalizeMaxInlineDiffBytes(value) {
33
+ const parsed = Number(value);
34
+ if (!Number.isFinite(parsed) || parsed < 0) {
35
+ return DEFAULT_INLINE_DIFF_MAX_BYTES;
36
+ }
37
+ return Math.floor(parsed);
38
+ }
39
+
40
+ function measureGitOutputBytes(cwd, args, maxBytes) {
41
+ const result = git(cwd, args, { maxBuffer: maxBytes + 1 });
42
+ if (result.error && /** @type {NodeJS.ErrnoException} */ (result.error).code === "ENOBUFS") {
43
+ return maxBytes + 1;
44
+ }
45
+ if (result.error) {
46
+ throw result.error;
47
+ }
48
+ if (result.status !== 0) {
49
+ throw new Error(formatCommandFailure(result));
50
+ }
51
+ return Buffer.byteLength(result.stdout, "utf8");
52
+ }
53
+
54
+ function measureCombinedGitOutputBytes(cwd, argSets, maxBytes) {
55
+ let totalBytes = 0;
56
+ for (const args of argSets) {
57
+ const remainingBytes = maxBytes - totalBytes;
58
+ if (remainingBytes < 0) {
59
+ return maxBytes + 1;
60
+ }
61
+ totalBytes += measureGitOutputBytes(cwd, args, remainingBytes);
62
+ if (totalBytes > maxBytes) {
63
+ return totalBytes;
64
+ }
65
+ }
66
+ return totalBytes;
67
+ }
68
+
69
+ function buildBranchComparison(cwd, baseRef) {
70
+ const mergeBase = gitChecked(cwd, ["merge-base", "HEAD", baseRef]).stdout.trim();
71
+ return {
72
+ mergeBase,
73
+ commitRange: `${mergeBase}..HEAD`,
74
+ reviewRange: `${baseRef}...HEAD`
75
+ };
76
+ }
77
+
78
+ export function ensureGitRepository(cwd) {
79
+ const result = git(cwd, ["rev-parse", "--show-toplevel"]);
80
+ const errorCode = result.error && "code" in result.error ? result.error.code : null;
81
+ if (errorCode === "ENOENT") {
82
+ throw new Error("git is not installed. Install Git and retry.");
83
+ }
84
+ if (result.status !== 0) {
85
+ throw new Error("This command must run inside a Git repository.");
86
+ }
87
+ return result.stdout.trim();
88
+ }
89
+
90
+ export function getRepoRoot(cwd) {
91
+ return gitChecked(cwd, ["rev-parse", "--show-toplevel"]).stdout.trim();
92
+ }
93
+
94
+ export function detectDefaultBranch(cwd) {
95
+ const symbolic = git(cwd, ["symbolic-ref", "refs/remotes/origin/HEAD"]);
96
+ if (symbolic.status === 0) {
97
+ const remoteHead = symbolic.stdout.trim();
98
+ if (remoteHead.startsWith("refs/remotes/origin/")) {
99
+ return remoteHead.replace("refs/remotes/origin/", "");
100
+ }
101
+ }
102
+
103
+ const candidates = ["main", "master", "trunk"];
104
+ for (const candidate of candidates) {
105
+ const local = git(cwd, ["show-ref", "--verify", "--quiet", `refs/heads/${candidate}`]);
106
+ if (local.status === 0) {
107
+ return candidate;
108
+ }
109
+ const remote = git(cwd, ["show-ref", "--verify", "--quiet", `refs/remotes/origin/${candidate}`]);
110
+ if (remote.status === 0) {
111
+ return `origin/${candidate}`;
112
+ }
113
+ }
114
+
115
+ throw new Error("Unable to detect the repository default branch. Pass --base <ref> or use --scope working-tree.");
116
+ }
117
+
118
+ export function getCurrentBranch(cwd) {
119
+ return gitChecked(cwd, ["branch", "--show-current"]).stdout.trim() || "HEAD";
120
+ }
121
+
122
+ export function getWorkingTreeState(cwd) {
123
+ const staged = gitChecked(cwd, ["diff", "--cached", "--name-only"]).stdout.trim().split("\n").filter(Boolean);
124
+ const unstaged = gitChecked(cwd, ["diff", "--name-only"]).stdout.trim().split("\n").filter(Boolean);
125
+ const untracked = gitChecked(cwd, ["ls-files", "--others", "--exclude-standard"]).stdout.trim().split("\n").filter(Boolean);
126
+
127
+ return {
128
+ staged,
129
+ unstaged,
130
+ untracked,
131
+ isDirty: staged.length > 0 || unstaged.length > 0 || untracked.length > 0
132
+ };
133
+ }
134
+
135
+ export function resolveReviewTarget(cwd, options = {}) {
136
+ ensureGitRepository(cwd);
137
+
138
+ const requestedScope = options.scope ?? "auto";
139
+ const baseRef = options.base ?? null;
140
+ const state = getWorkingTreeState(cwd);
141
+ const supportedScopes = new Set(["auto", "working-tree", "branch"]);
142
+
143
+ if (baseRef) {
144
+ return {
145
+ mode: "branch",
146
+ label: `branch diff against ${baseRef}`,
147
+ baseRef,
148
+ explicit: true
149
+ };
150
+ }
151
+
152
+ if (requestedScope === "working-tree") {
153
+ return {
154
+ mode: "working-tree",
155
+ label: "working tree diff",
156
+ explicit: true
157
+ };
158
+ }
159
+
160
+ if (!supportedScopes.has(requestedScope)) {
161
+ throw new Error(
162
+ `Unsupported review scope "${requestedScope}". Use one of: auto, working-tree, branch, or pass --base <ref>.`
163
+ );
164
+ }
165
+
166
+ if (requestedScope === "branch") {
167
+ const detectedBase = detectDefaultBranch(cwd);
168
+ return {
169
+ mode: "branch",
170
+ label: `branch diff against ${detectedBase}`,
171
+ baseRef: detectedBase,
172
+ explicit: true
173
+ };
174
+ }
175
+
176
+ if (state.isDirty) {
177
+ return {
178
+ mode: "working-tree",
179
+ label: "working tree diff",
180
+ explicit: false
181
+ };
182
+ }
183
+
184
+ const detectedBase = detectDefaultBranch(cwd);
185
+ return {
186
+ mode: "branch",
187
+ label: `branch diff against ${detectedBase}`,
188
+ baseRef: detectedBase,
189
+ explicit: false
190
+ };
191
+ }
192
+
193
+ function formatSection(title, body) {
194
+ return [`## ${title}`, "", body.trim() ? body.trim() : "(none)", ""].join("\n");
195
+ }
196
+
197
+ function formatUntrackedFile(cwd, relativePath) {
198
+ const absolutePath = path.join(cwd, relativePath);
199
+ let stat;
200
+ try {
201
+ stat = fs.statSync(absolutePath);
202
+ } catch {
203
+ return `### ${relativePath}\n(skipped: broken symlink or unreadable file)`;
204
+ }
205
+ if (stat.isDirectory()) {
206
+ return `### ${relativePath}\n(skipped: directory)`;
207
+ }
208
+ if (stat.size > MAX_UNTRACKED_BYTES) {
209
+ return `### ${relativePath}\n(skipped: ${stat.size} bytes exceeds ${MAX_UNTRACKED_BYTES} byte limit)`;
210
+ }
211
+
212
+ let buffer;
213
+ try {
214
+ buffer = fs.readFileSync(absolutePath);
215
+ } catch {
216
+ return `### ${relativePath}\n(skipped: broken symlink or unreadable file)`;
217
+ }
218
+ if (!isProbablyText(buffer)) {
219
+ return `### ${relativePath}\n(skipped: binary file)`;
220
+ }
221
+
222
+ return [`### ${relativePath}`, "```", buffer.toString("utf8").trimEnd(), "```"].join("\n");
223
+ }
224
+
225
+ function collectWorkingTreeContext(cwd, state, options = {}) {
226
+ const includeDiff = options.includeDiff !== false;
227
+ const status = gitChecked(cwd, ["status", "--short", "--untracked-files=all"]).stdout.trim();
228
+ const changedFiles = listUniqueFiles(state.staged, state.unstaged, state.untracked);
229
+
230
+ let parts;
231
+ if (includeDiff) {
232
+ const stagedDiff = gitChecked(cwd, ["diff", "--cached", "--binary", "--no-ext-diff", "--submodule=diff"]).stdout;
233
+ const unstagedDiff = gitChecked(cwd, ["diff", "--binary", "--no-ext-diff", "--submodule=diff"]).stdout;
234
+ const untrackedBody = state.untracked.map((file) => formatUntrackedFile(cwd, file)).join("\n\n");
235
+ parts = [
236
+ formatSection("Git Status", status),
237
+ formatSection("Staged Diff", stagedDiff),
238
+ formatSection("Unstaged Diff", unstagedDiff),
239
+ formatSection("Untracked Files", untrackedBody)
240
+ ];
241
+ } else {
242
+ const stagedStat = gitChecked(cwd, ["diff", "--shortstat", "--cached"]).stdout.trim();
243
+ const unstagedStat = gitChecked(cwd, ["diff", "--shortstat"]).stdout.trim();
244
+ const untrackedBody = state.untracked.map((file) => formatUntrackedFile(cwd, file)).join("\n\n");
245
+ parts = [
246
+ formatSection("Git Status", status),
247
+ formatSection("Staged Diff Stat", stagedStat),
248
+ formatSection("Unstaged Diff Stat", unstagedStat),
249
+ formatSection("Changed Files", changedFiles.join("\n")),
250
+ formatSection("Untracked Files", untrackedBody)
251
+ ];
252
+ }
253
+
254
+ return {
255
+ mode: "working-tree",
256
+ summary: `Reviewing ${state.staged.length} staged, ${state.unstaged.length} unstaged, and ${state.untracked.length} untracked file(s).`,
257
+ content: parts.join("\n"),
258
+ changedFiles
259
+ };
260
+ }
261
+
262
+ function collectBranchContext(cwd, baseRef, options = {}) {
263
+ const includeDiff = options.includeDiff !== false;
264
+ const comparison = options.comparison ?? buildBranchComparison(cwd, baseRef);
265
+ const currentBranch = getCurrentBranch(cwd);
266
+ const changedFiles = gitChecked(cwd, ["diff", "--name-only", comparison.commitRange]).stdout.trim().split("\n").filter(Boolean);
267
+ const logOutput = gitChecked(cwd, ["log", "--oneline", "--decorate", comparison.commitRange]).stdout.trim();
268
+ const diffStat = gitChecked(cwd, ["diff", "--stat", comparison.commitRange]).stdout.trim();
269
+
270
+ return {
271
+ mode: "branch",
272
+ summary: `Reviewing branch ${currentBranch} against ${baseRef} from merge-base ${comparison.mergeBase}.`,
273
+ content: includeDiff
274
+ ? [
275
+ formatSection("Commit Log", logOutput),
276
+ formatSection("Diff Stat", diffStat),
277
+ formatSection(
278
+ "Branch Diff",
279
+ gitChecked(cwd, ["diff", "--binary", "--no-ext-diff", "--submodule=diff", comparison.commitRange]).stdout
280
+ )
281
+ ].join("\n")
282
+ : [
283
+ formatSection("Commit Log", logOutput),
284
+ formatSection("Diff Stat", diffStat),
285
+ formatSection("Changed Files", changedFiles.join("\n"))
286
+ ].join("\n"),
287
+ changedFiles,
288
+ comparison
289
+ };
290
+ }
291
+
292
+ function buildAdversarialCollectionGuidance(options = {}) {
293
+ if (options.includeDiff !== false) {
294
+ return "Use the repository context below as primary evidence.";
295
+ }
296
+
297
+ return "The repository context below is a lightweight summary. Inspect the target diff yourself with read-only git commands before finalizing findings.";
298
+ }
299
+
300
+ export function collectReviewContext(cwd, target, options = {}) {
301
+ const repoRoot = getRepoRoot(cwd);
302
+ const currentBranch = getCurrentBranch(repoRoot);
303
+ const maxInlineFiles = normalizeMaxInlineFiles(options.maxInlineFiles);
304
+ const maxInlineDiffBytes = normalizeMaxInlineDiffBytes(options.maxInlineDiffBytes);
305
+ let details;
306
+ let includeDiff;
307
+ let diffBytes;
308
+
309
+ if (target.mode === "working-tree") {
310
+ const state = getWorkingTreeState(repoRoot);
311
+ diffBytes = measureCombinedGitOutputBytes(
312
+ repoRoot,
313
+ [
314
+ ["diff", "--cached", "--binary", "--no-ext-diff", "--submodule=diff"],
315
+ ["diff", "--binary", "--no-ext-diff", "--submodule=diff"]
316
+ ],
317
+ maxInlineDiffBytes
318
+ );
319
+ includeDiff =
320
+ options.includeDiff ??
321
+ (listUniqueFiles(state.staged, state.unstaged, state.untracked).length <= maxInlineFiles &&
322
+ diffBytes <= maxInlineDiffBytes);
323
+ details = collectWorkingTreeContext(repoRoot, state, { includeDiff });
324
+ } else {
325
+ const comparison = buildBranchComparison(repoRoot, target.baseRef);
326
+ const fileCount = gitChecked(repoRoot, ["diff", "--name-only", comparison.commitRange]).stdout.trim().split("\n").filter(Boolean).length;
327
+ diffBytes = measureGitOutputBytes(
328
+ repoRoot,
329
+ ["diff", "--binary", "--no-ext-diff", "--submodule=diff", comparison.commitRange],
330
+ maxInlineDiffBytes
331
+ );
332
+ includeDiff = options.includeDiff ?? (fileCount <= maxInlineFiles && diffBytes <= maxInlineDiffBytes);
333
+ details = collectBranchContext(repoRoot, target.baseRef, { includeDiff, comparison });
334
+ }
335
+
336
+ return {
337
+ cwd: repoRoot,
338
+ repoRoot,
339
+ branch: currentBranch,
340
+ target,
341
+ fileCount: details.changedFiles.length,
342
+ diffBytes,
343
+ inputMode: includeDiff ? "inline-diff" : "self-collect",
344
+ collectionGuidance: buildAdversarialCollectionGuidance({ includeDiff }),
345
+ ...details
346
+ };
347
+ }
@@ -0,0 +1,308 @@
1
+ import fs from "node:fs";
2
+
3
+ import { getSessionRuntimeStatus } from "./codex.mjs";
4
+ import { getConfig, listJobs, readJobFile, resolveJobFile } from "./state.mjs";
5
+ import { SESSION_ID_ENV } from "./tracked-jobs.mjs";
6
+ import { resolveWorkspaceRoot } from "./workspace.mjs";
7
+
8
+ export const DEFAULT_MAX_STATUS_JOBS = 8;
9
+ export const DEFAULT_MAX_PROGRESS_LINES = 4;
10
+
11
+ export function sortJobsNewestFirst(jobs) {
12
+ return [...jobs].sort((left, right) => String(right.updatedAt ?? "").localeCompare(String(left.updatedAt ?? "")));
13
+ }
14
+
15
+ function getCurrentSessionId(options = {}) {
16
+ return options.env?.[SESSION_ID_ENV] ?? process.env[SESSION_ID_ENV] ?? null;
17
+ }
18
+
19
+ function filterJobsForCurrentSession(jobs, options = {}) {
20
+ const sessionId = getCurrentSessionId(options);
21
+ if (!sessionId) {
22
+ return jobs;
23
+ }
24
+ return jobs.filter((job) => job.sessionId === sessionId);
25
+ }
26
+
27
+ function getJobTypeLabel(job) {
28
+ if (typeof job.kindLabel === "string" && job.kindLabel) {
29
+ return job.kindLabel;
30
+ }
31
+ if (job.kind === "adversarial-review") {
32
+ return "adversarial-review";
33
+ }
34
+ if (job.jobClass === "review") {
35
+ return "review";
36
+ }
37
+ if (job.jobClass === "task") {
38
+ return "rescue";
39
+ }
40
+ if (job.kind === "review") {
41
+ return "review";
42
+ }
43
+ if (job.kind === "task") {
44
+ return "rescue";
45
+ }
46
+ return "job";
47
+ }
48
+
49
+ function stripLogPrefix(line) {
50
+ return line.replace(/^\[[^\]]+\]\s*/, "").trim();
51
+ }
52
+
53
+ function isProgressBlockTitle(line) {
54
+ return (
55
+ ["Final output", "Assistant message", "Reasoning summary", "Review output"].includes(line) ||
56
+ /^Subagent .+ message$/.test(line) ||
57
+ /^Subagent .+ reasoning summary$/.test(line)
58
+ );
59
+ }
60
+
61
+ export function readJobProgressPreview(logFile, maxLines = DEFAULT_MAX_PROGRESS_LINES) {
62
+ if (!logFile || !fs.existsSync(logFile)) {
63
+ return [];
64
+ }
65
+
66
+ const lines = fs
67
+ .readFileSync(logFile, "utf8")
68
+ .split(/\r?\n/)
69
+ .map((line) => line.trimEnd())
70
+ .filter(Boolean)
71
+ .filter((line) => line.startsWith("["))
72
+ .map(stripLogPrefix)
73
+ .filter((line) => line && !isProgressBlockTitle(line));
74
+
75
+ return lines.slice(-maxLines);
76
+ }
77
+
78
+ function formatElapsedDuration(startValue, endValue = null) {
79
+ const start = Date.parse(startValue ?? "");
80
+ if (!Number.isFinite(start)) {
81
+ return null;
82
+ }
83
+
84
+ const end = endValue ? Date.parse(endValue) : Date.now();
85
+ if (!Number.isFinite(end) || end < start) {
86
+ return null;
87
+ }
88
+
89
+ const totalSeconds = Math.max(0, Math.round((end - start) / 1000));
90
+ const hours = Math.floor(totalSeconds / 3600);
91
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
92
+ const seconds = totalSeconds % 60;
93
+
94
+ if (hours > 0) {
95
+ return `${hours}h ${minutes}m`;
96
+ }
97
+ if (minutes > 0) {
98
+ return `${minutes}m ${seconds}s`;
99
+ }
100
+ return `${seconds}s`;
101
+ }
102
+
103
+ function looksLikeVerificationCommand(line) {
104
+ return /\b(test|tests|lint|build|typecheck|type-check|check|verify|validate|pytest|jest|vitest|cargo test|npm test|pnpm test|yarn test|go test|mvn test|gradle test|tsc|eslint|ruff)\b/i.test(
105
+ line
106
+ );
107
+ }
108
+
109
+ function inferLegacyJobPhase(job, progressPreview = []) {
110
+ switch (job.status) {
111
+ case "queued":
112
+ return "queued";
113
+ case "cancelled":
114
+ return "cancelled";
115
+ case "failed":
116
+ return "failed";
117
+ case "completed":
118
+ return "done";
119
+ default:
120
+ break;
121
+ }
122
+
123
+ for (let index = progressPreview.length - 1; index >= 0; index -= 1) {
124
+ const line = progressPreview[index].toLowerCase();
125
+ if (line.startsWith("starting codex") || line.startsWith("thread ready") || line.startsWith("turn started")) {
126
+ return "starting";
127
+ }
128
+ if (line.startsWith("reviewer started") || line.includes("review mode")) {
129
+ return "reviewing";
130
+ }
131
+ if (line.startsWith("searching:") || line.startsWith("calling ") || line.startsWith("running tool:")) {
132
+ return "investigating";
133
+ }
134
+ if (line.startsWith("starting collaboration tool:")) {
135
+ return "investigating";
136
+ }
137
+ if (line.startsWith("running command:")) {
138
+ return looksLikeVerificationCommand(line)
139
+ ? "verifying"
140
+ : job.jobClass === "review"
141
+ ? "reviewing"
142
+ : "investigating";
143
+ }
144
+ if (line.startsWith("command completed:")) {
145
+ return looksLikeVerificationCommand(line) ? "verifying" : "running";
146
+ }
147
+ if (line.startsWith("applying ") || line.startsWith("file changes ")) {
148
+ return "editing";
149
+ }
150
+ if (line.startsWith("turn completed")) {
151
+ return "finalizing";
152
+ }
153
+ if (line.startsWith("codex error:") || line.startsWith("failed:")) {
154
+ return "failed";
155
+ }
156
+ }
157
+
158
+ return job.jobClass === "review" ? "reviewing" : "running";
159
+ }
160
+
161
+ export function enrichJob(job, options = {}) {
162
+ const maxProgressLines = options.maxProgressLines ?? DEFAULT_MAX_PROGRESS_LINES;
163
+ const enriched = {
164
+ ...job,
165
+ kindLabel: getJobTypeLabel(job),
166
+ progressPreview:
167
+ job.status === "queued" || job.status === "running" || job.status === "failed"
168
+ ? readJobProgressPreview(job.logFile, maxProgressLines)
169
+ : [],
170
+ elapsed: formatElapsedDuration(job.startedAt ?? job.createdAt, job.completedAt ?? null),
171
+ duration:
172
+ job.status === "completed" || job.status === "failed" || job.status === "cancelled"
173
+ ? formatElapsedDuration(job.startedAt ?? job.createdAt, job.completedAt ?? job.updatedAt)
174
+ : null
175
+ };
176
+
177
+ return {
178
+ ...enriched,
179
+ phase: enriched.phase ?? inferLegacyJobPhase(enriched, enriched.progressPreview)
180
+ };
181
+ }
182
+
183
+ export function readStoredJob(workspaceRoot, jobId) {
184
+ const jobFile = resolveJobFile(workspaceRoot, jobId);
185
+ if (!fs.existsSync(jobFile)) {
186
+ return null;
187
+ }
188
+ return readJobFile(jobFile);
189
+ }
190
+
191
+ function matchJobReference(jobs, reference, predicate = () => true) {
192
+ const filtered = jobs.filter(predicate);
193
+ if (!reference) {
194
+ return filtered[0] ?? null;
195
+ }
196
+
197
+ const exact = filtered.find((job) => job.id === reference);
198
+ if (exact) {
199
+ return exact;
200
+ }
201
+
202
+ const prefixMatches = filtered.filter((job) => job.id.startsWith(reference));
203
+ if (prefixMatches.length === 1) {
204
+ return prefixMatches[0];
205
+ }
206
+ if (prefixMatches.length > 1) {
207
+ throw new Error(`Job reference "${reference}" is ambiguous. Use a longer job id.`);
208
+ }
209
+
210
+ throw new Error(`No job found for "${reference}". Run /codex:status to list known jobs.`);
211
+ }
212
+
213
+ export function buildStatusSnapshot(cwd, options = {}) {
214
+ const workspaceRoot = resolveWorkspaceRoot(cwd);
215
+ const config = getConfig(workspaceRoot);
216
+ const jobs = sortJobsNewestFirst(filterJobsForCurrentSession(listJobs(workspaceRoot), options));
217
+ const maxJobs = options.maxJobs ?? DEFAULT_MAX_STATUS_JOBS;
218
+ const maxProgressLines = options.maxProgressLines ?? DEFAULT_MAX_PROGRESS_LINES;
219
+
220
+ const running = jobs
221
+ .filter((job) => job.status === "queued" || job.status === "running")
222
+ .map((job) => enrichJob(job, { maxProgressLines }));
223
+
224
+ const latestFinishedRaw = jobs.find((job) => job.status !== "queued" && job.status !== "running") ?? null;
225
+ const latestFinished = latestFinishedRaw ? enrichJob(latestFinishedRaw, { maxProgressLines }) : null;
226
+
227
+ const recent = (options.all ? jobs : jobs.slice(0, maxJobs))
228
+ .filter((job) => job.status !== "queued" && job.status !== "running" && job.id !== latestFinished?.id)
229
+ .map((job) => enrichJob(job, { maxProgressLines }));
230
+
231
+ return {
232
+ workspaceRoot,
233
+ config,
234
+ sessionRuntime: getSessionRuntimeStatus(options.env, workspaceRoot),
235
+ running,
236
+ latestFinished,
237
+ recent,
238
+ needsReview: Boolean(config.stopReviewGate)
239
+ };
240
+ }
241
+
242
+ export function buildSingleJobSnapshot(cwd, reference, options = {}) {
243
+ const workspaceRoot = resolveWorkspaceRoot(cwd);
244
+ const jobs = sortJobsNewestFirst(listJobs(workspaceRoot));
245
+ const selected = matchJobReference(jobs, reference);
246
+ if (!selected) {
247
+ throw new Error(`No job found for "${reference}". Run /codex:status to inspect known jobs.`);
248
+ }
249
+
250
+ return {
251
+ workspaceRoot,
252
+ job: enrichJob(selected, { maxProgressLines: options.maxProgressLines })
253
+ };
254
+ }
255
+
256
+ export function resolveResultJob(cwd, reference) {
257
+ const workspaceRoot = resolveWorkspaceRoot(cwd);
258
+ const jobs = sortJobsNewestFirst(reference ? listJobs(workspaceRoot) : filterJobsForCurrentSession(listJobs(workspaceRoot)));
259
+ const selected = matchJobReference(
260
+ jobs,
261
+ reference,
262
+ (job) => job.status === "completed" || job.status === "failed" || job.status === "cancelled"
263
+ );
264
+
265
+ if (selected) {
266
+ return { workspaceRoot, job: selected };
267
+ }
268
+
269
+ const active = matchJobReference(jobs, reference, (job) => job.status === "queued" || job.status === "running");
270
+ if (active) {
271
+ throw new Error(`Job ${active.id} is still ${active.status}. Check /codex:status and try again once it finishes.`);
272
+ }
273
+
274
+ if (reference) {
275
+ throw new Error(`No finished job found for "${reference}". Run /codex:status to inspect active jobs.`);
276
+ }
277
+
278
+ throw new Error("No finished Codex jobs found for this repository yet.");
279
+ }
280
+
281
+ export function resolveCancelableJob(cwd, reference, options = {}) {
282
+ const workspaceRoot = resolveWorkspaceRoot(cwd);
283
+ const jobs = sortJobsNewestFirst(listJobs(workspaceRoot));
284
+ const activeJobs = jobs.filter((job) => job.status === "queued" || job.status === "running");
285
+
286
+ if (reference) {
287
+ const selected = matchJobReference(activeJobs, reference);
288
+ if (!selected) {
289
+ throw new Error(`No active job found for "${reference}".`);
290
+ }
291
+ return { workspaceRoot, job: selected };
292
+ }
293
+
294
+ const sessionScopedActiveJobs = filterJobsForCurrentSession(activeJobs, options);
295
+
296
+ if (sessionScopedActiveJobs.length === 1) {
297
+ return { workspaceRoot, job: sessionScopedActiveJobs[0] };
298
+ }
299
+ if (sessionScopedActiveJobs.length > 1) {
300
+ throw new Error("Multiple Codex jobs are active. Pass a job id to /codex:cancel.");
301
+ }
302
+
303
+ if (getCurrentSessionId(options)) {
304
+ throw new Error("No active Codex jobs to cancel for this session.");
305
+ }
306
+
307
+ throw new Error("No active Codex jobs to cancel.");
308
+ }