@lyupro/codex-bridge 0.1.3 → 0.2.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 (45) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +18 -4
  3. package/bin/codex-bridge.mjs +29 -0
  4. package/cli/doctor.mjs +26 -3
  5. package/cli/install.mjs +18 -4
  6. package/cli/manifest.mjs +6 -4
  7. package/cli/projects.mjs +92 -0
  8. package/cli/prune-args.mjs +133 -0
  9. package/cli/prune-plan.mjs +205 -0
  10. package/cli/prune.mjs +278 -0
  11. package/cli/read.mjs +103 -0
  12. package/cli/run-lookup.mjs +59 -0
  13. package/cli/runs-inventory.mjs +173 -0
  14. package/cli/settings-merge.mjs +18 -4
  15. package/cli/stop.mjs +4 -37
  16. package/cli/table.mjs +110 -0
  17. package/package.json +1 -1
  18. package/src/agents/codex-build.md +12 -10
  19. package/src/agents/codex-review.md +11 -5
  20. package/src/agents/codex-scout.md +11 -10
  21. package/src/commands/usage.md +4 -3
  22. package/src/hook-definitions.mjs +22 -0
  23. package/src/hooks/live-runs.mjs +98 -0
  24. package/src/hooks/prune-guard.mjs +62 -0
  25. package/src/hooks/reply-guard.mjs +45 -22
  26. package/src/hooks/worktree-lock.mjs +70 -0
  27. package/src/meta/deadline.mjs +19 -0
  28. package/src/meta/events.mjs +166 -0
  29. package/src/meta/reason.mjs +29 -0
  30. package/src/meta/reply.mjs +40 -7
  31. package/src/meta/run-state.mjs +7 -1
  32. package/src/meta/startup.mjs +21 -0
  33. package/src/meta/transport.mjs +35 -0
  34. package/src/meta/verdict.mjs +40 -42
  35. package/src/required-inputs.mjs +49 -6
  36. package/src/retention.mjs +120 -0
  37. package/src/run-codex.mjs +3 -3
  38. package/src/run-config.json +5 -1
  39. package/src/run-config.mjs +54 -1
  40. package/src/runner/attach.mjs +1 -1
  41. package/src/runner/codex-cmd.mjs +119 -31
  42. package/src/runner/continuation.mjs +107 -0
  43. package/src/runner/launcher.mjs +23 -40
  44. package/src/runner/worker.mjs +11 -3
  45. package/src/write-meta.mjs +31 -22
@@ -0,0 +1,205 @@
1
+ /** Builds a read-only prune plan from run inventory data and folder-name ages. */
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { listProjectRuns, listProjects, recursiveSize } from './runs-inventory.mjs';
5
+ import { parseOlderThan } from './prune-args.mjs';
6
+ import { runsRoot } from '../src/runner/runs-root.mjs';
7
+ import { runIsOlderThan, TRANSPORT_FILES } from '../src/retention.mjs';
8
+
9
+ export { runIsOlderThan, TRANSPORT_FILES };
10
+
11
+ function safeFolderName(value) {
12
+ return typeof value === 'string'
13
+ && value.trim()
14
+ && value !== '.'
15
+ && value !== '..'
16
+ && !path.isAbsolute(value)
17
+ && path.dirname(value) === '.';
18
+ }
19
+
20
+ function normalizeOlderThan(value) {
21
+ if (!value) return null;
22
+ if (typeof value === 'string') {
23
+ const parsed = parseOlderThan(value);
24
+ return parsed.error ? null : parsed;
25
+ }
26
+ return value;
27
+ }
28
+
29
+ function fileDetails(runDir) {
30
+ return TRANSPORT_FILES.flatMap((name) => {
31
+ const target = path.join(runDir, name);
32
+ try {
33
+ const stat = fs.lstatSync(target);
34
+ if (!stat.isFile()) return [];
35
+ return [{ name, path: target, bytes: stat.size }];
36
+ } catch {
37
+ return [];
38
+ }
39
+ });
40
+ }
41
+
42
+ function sumBytes(items) {
43
+ return items.every((item) => Number.isFinite(item.bytes))
44
+ ? items.reduce((sum, item) => sum + item.bytes, 0)
45
+ : null;
46
+ }
47
+
48
+ function gentleAction(root, project, run) {
49
+ const runDir = path.join(root, project, run.run);
50
+ const files = fileDetails(runDir);
51
+ if (!files.length) return null;
52
+ return {
53
+ kind: 'run',
54
+ mode: 'gentle',
55
+ project,
56
+ run: run.run,
57
+ path: runDir,
58
+ files,
59
+ targets: files.map((file) => file.path),
60
+ bytes: sumBytes(files),
61
+ };
62
+ }
63
+
64
+ function runPurgeAction(root, project, run) {
65
+ const runDir = path.join(root, project, run.run);
66
+ return {
67
+ kind: 'run',
68
+ mode: 'purge',
69
+ project,
70
+ run: run.run,
71
+ path: runDir,
72
+ files: [],
73
+ targets: [runDir],
74
+ bytes: recursiveSize(runDir),
75
+ };
76
+ }
77
+
78
+ function projectPurgeAction(root, project, runs) {
79
+ const projectDir = path.join(root, project);
80
+ return {
81
+ kind: 'project',
82
+ mode: 'purge',
83
+ project,
84
+ run: null,
85
+ path: projectDir,
86
+ files: [],
87
+ targets: [projectDir],
88
+ bytes: recursiveSize(projectDir),
89
+ runs: runs.map((run) => run.run),
90
+ };
91
+ }
92
+
93
+ function selectedRuns(runs, olderThan, now) {
94
+ return olderThan ? runs.filter((run) => runIsOlderThan(run.run, olderThan, now)) : runs;
95
+ }
96
+
97
+ /**
98
+ * A live run is never a prune target. Its events.jsonl is the verdict being written: delete it and
99
+ * the run ends with "artifacts disagree" over work that was actually done — the exact loss this
100
+ * package spent Plan_15 and Plan_16 removing. The inventory already marks liveness through
101
+ * hooks/live-runs.mjs, so this costs nothing and asks no second opinion.
102
+ */
103
+ function withoutLiveRuns(runs) {
104
+ // By the pid probe, not by status.json: an abandoned run also reads `running` there, and those
105
+ // are exactly the folders worth cleaning.
106
+ return runs.filter((run) => run.live !== true);
107
+ }
108
+
109
+ function projectNames(root) {
110
+ return listProjects(root).map((project) => project.project);
111
+ }
112
+
113
+ function projectRuns(root, project) {
114
+ return listProjectRuns(root, project);
115
+ }
116
+
117
+ function broadProjectPlan(root, project, args, age, now) {
118
+ const all = projectRuns(root, project);
119
+ if (all === null) return { error: `codex-bridge prune: unknown project "${project}"` };
120
+ const live = all.filter((run) => run.live === true);
121
+ if (args.purge && live.length) {
122
+ return {
123
+ actions: [],
124
+ note: `${live.length} run(s) in "${project}" are still going; a project purge would delete a run that is writing right now`,
125
+ };
126
+ }
127
+ const runs = withoutLiveRuns(all);
128
+ const selected = selectedRuns(runs, age, now);
129
+ if (args.purge) {
130
+ // A whole-project purge must not let a recent or undated run hide behind an old one. Saying
131
+ // why matters as much as refusing: an empty plan without a reason reads as a broken command,
132
+ // and the operator's next move is to try again harder.
133
+ if (selected.length !== runs.length) {
134
+ return {
135
+ actions: [],
136
+ note: `${runs.length - selected.length} of ${runs.length} runs in "${project}" are newer than the age filter, so the whole-project purge is refused; drop --older-than to purge the folder, or prune the old runs one by one`,
137
+ };
138
+ }
139
+ return { actions: [projectPurgeAction(root, project, runs)] };
140
+ }
141
+ return {
142
+ actions: selected.map((run) => gentleAction(root, project, run)).filter(Boolean),
143
+ };
144
+ }
145
+
146
+ function namedRunPlan(root, project, runName, args, age, now) {
147
+ const runs = projectRuns(root, project);
148
+ if (runs === null) return { error: `codex-bridge prune: unknown project "${project}"` };
149
+ const run = runs.find((entry) => entry.run === runName);
150
+ if (!run) return { error: `codex-bridge prune: unknown run "${runName}" in project "${project}"` };
151
+ if (run.live === true) {
152
+ return {
153
+ error: `codex-bridge prune: run "${runName}" is still going; its events.jsonl is the verdict being written`,
154
+ };
155
+ }
156
+ if (age && !runIsOlderThan(run.run, age, now)) return { actions: [] };
157
+ const action = args.purge
158
+ ? runPurgeAction(root, project, run)
159
+ : gentleAction(root, project, run);
160
+ return { actions: action ? [action] : [] };
161
+ }
162
+
163
+ /** Purely decides what prune would remove; it never writes or deletes a filesystem entry. */
164
+ export function prunePlan(args = {}, options = {}) {
165
+ const root = path.resolve(options.runsRootPath || args.runsRootPath || runsRoot());
166
+ const now = options.now ?? Date.now();
167
+ const scope = args.allProjects ? 'all-projects' : args.runName ? 'run' : 'project';
168
+ const age = normalizeOlderThan(args.olderThan);
169
+
170
+ let actions = [];
171
+ let note = null;
172
+ if (args.allProjects) {
173
+ for (const project of projectNames(root)) {
174
+ const result = broadProjectPlan(root, project, { ...args, purge: false }, age, now);
175
+ if (result.error) return result;
176
+ actions.push(...result.actions);
177
+ }
178
+ } else if (!safeFolderName(args.projectName)) {
179
+ return { error: 'codex-bridge prune: a bare project folder name is required' };
180
+ } else if (args.runName) {
181
+ if (!safeFolderName(args.runName)) {
182
+ return { error: 'codex-bridge prune: a bare run folder name is required' };
183
+ }
184
+ const result = namedRunPlan(root, args.projectName, args.runName, args, age, now);
185
+ if (result.error) return result;
186
+ actions = result.actions;
187
+ } else {
188
+ const result = broadProjectPlan(root, args.projectName, args, age, now);
189
+ if (result.error) return result;
190
+ actions = result.actions;
191
+ note = result.note ?? null;
192
+ }
193
+
194
+ return {
195
+ root,
196
+ scope,
197
+ mode: args.purge ? 'purge' : 'gentle',
198
+ project: args.projectName || null,
199
+ run: args.runName || null,
200
+ olderThan: age,
201
+ actions,
202
+ note,
203
+ bytes: sumBytes(actions),
204
+ };
205
+ }
package/cli/prune.mjs ADDED
@@ -0,0 +1,278 @@
1
+ /** Confirms, executes, and reports the destructive actions in a prune plan. */
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import readline from 'node:readline/promises';
5
+ import { normalizePath } from '../src/hooks/live-runs.mjs';
6
+ import { parsePruneArgs } from './prune-args.mjs';
7
+ import { prunePlan } from './prune-plan.mjs';
8
+ import { recursiveSize } from './runs-inventory.mjs';
9
+
10
+ function formatBytes(bytes) {
11
+ if (!Number.isFinite(bytes)) return 'unknown';
12
+ if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
13
+ if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
14
+ if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
15
+ return `${bytes} B`;
16
+ }
17
+
18
+ function ageText(olderThan) {
19
+ if (!olderThan) return null;
20
+ return olderThan.kind === 'date' ? olderThan.date : `${olderThan.amount}${olderThan.unit}`;
21
+ }
22
+
23
+ function commandText(args) {
24
+ const scope = args.allProjects
25
+ ? '--all-projects'
26
+ : [args.projectName, args.runName].filter(Boolean).join(' ');
27
+ const flags = [];
28
+ if (args.purge) flags.push('--purge');
29
+ if (args.olderThanExplicit) flags.push('--older-than', ageText(args.olderThan));
30
+ flags.push('-f');
31
+ return ['codex-bridge prune', scope, ...flags].join(' ');
32
+ }
33
+
34
+ function actionPayload(action) {
35
+ return {
36
+ kind: action.kind,
37
+ mode: action.mode,
38
+ project: action.project,
39
+ run: action.run,
40
+ targets: action.targets,
41
+ bytes: action.bytes,
42
+ };
43
+ }
44
+
45
+ function basePayload(plan) {
46
+ return {
47
+ scope: plan.scope,
48
+ mode: plan.mode,
49
+ project: plan.project,
50
+ run: plan.run,
51
+ olderThan: ageText(plan.olderThan),
52
+ note: plan.note ?? null,
53
+ actions: plan.actions.map(actionPayload),
54
+ bytes: plan.bytes,
55
+ };
56
+ }
57
+
58
+ function planLines(plan, args) {
59
+ const lines = [
60
+ `Prune plan: ${plan.mode} ${plan.scope}.`,
61
+ plan.olderThan ? `Age filter: older than ${ageText(plan.olderThan)} (folder name date).` : 'Age filter: none.',
62
+ ];
63
+ for (const action of plan.actions) {
64
+ for (const target of action.targets) lines.push(`Would remove ${target}`);
65
+ }
66
+ if (!plan.actions.length) {
67
+ lines.push(plan.note ? `Nothing would be removed: ${plan.note}.` : 'Nothing would be removed.');
68
+ }
69
+ lines.push(`Space to free: ${formatBytes(plan.bytes)}.`);
70
+ lines.push('Nothing was deleted.');
71
+ lines.push(`To execute: ${commandText(args)}`);
72
+ return lines.join('\n');
73
+ }
74
+
75
+ function reportLines(payload, status) {
76
+ const lines = [
77
+ status === 'completed'
78
+ ? 'Prune completed.'
79
+ : status === 'cancelled'
80
+ ? 'Prune cancelled; no files were deleted.'
81
+ : 'Prune failed.',
82
+ ];
83
+ if (payload.removed.length) {
84
+ lines.push('Removed:');
85
+ for (const target of payload.removed) lines.push(` ${target}`);
86
+ } else {
87
+ lines.push('Removed: nothing.');
88
+ }
89
+ lines.push(`Space freed: ${formatBytes(payload.bytesFreed)}.`);
90
+ if (payload.failed?.length) {
91
+ lines.push('Failed:');
92
+ for (const failure of payload.failed) lines.push(` ${failure.target}: ${failure.error}`);
93
+ }
94
+ return lines.join('\n');
95
+ }
96
+
97
+ function output(value, json) {
98
+ return json ? JSON.stringify(value, null, 2) : value.error || reportLines(value, value.status);
99
+ }
100
+
101
+ function result(exitCode, text) {
102
+ return { exitCode, output: text };
103
+ }
104
+
105
+ // One spelling only. A guard that answers to several option names is a guard with several ways
106
+ // to be switched off by accident.
107
+ function tty(options) {
108
+ if (options.isTTY !== undefined) return Boolean(options.isTTY);
109
+ return Boolean((options.stdin || process.stdin).isTTY);
110
+ }
111
+
112
+ async function defaultPrompt(question, options = {}) {
113
+ const input = options.stdin || process.stdin;
114
+ const outputStream = options.promptOutput || process.stderr;
115
+ const interfaceHandle = readline.createInterface({ input, output: outputStream });
116
+ try {
117
+ const answer = await interfaceHandle.question(`${question} [y/N] `);
118
+ return /^(y|yes)$/i.test(answer.trim());
119
+ } finally {
120
+ interfaceHandle.close();
121
+ }
122
+ }
123
+
124
+ function accepted(answer) {
125
+ return answer === true || /^(y|yes)$/i.test(String(answer ?? '').trim());
126
+ }
127
+
128
+ // The package compares paths through one function, and this guard is the last thing standing
129
+ // between a bug and rm -r. Raw string comparison is case-sensitive: a runs root spelled
130
+ // `C:\Users\...` against a target resolved as `c:\users\...` would put every target "outside the
131
+ // root", and the deletion would fail with a reason that names the wrong problem.
132
+ function targetInsideRoot(target, root) {
133
+ const normalizedRoot = normalizePath(root);
134
+ const normalizedTarget = normalizePath(target);
135
+ if (!normalizedRoot || !normalizedTarget) return false;
136
+ return normalizedTarget !== normalizedRoot && normalizedTarget.startsWith(`${normalizedRoot}/`);
137
+ }
138
+
139
+ /**
140
+ * Refuses a target reached through a link. Independent review found that lexical containment alone
141
+ * is satisfiable: replace `<root>/<project>` with a junction to an external directory and a target
142
+ * that still spells out as inside the root deletes what is outside it. The package deliberately
143
+ * does not resolve paths (`realpath` returns `\\?\` and UNC forms on Windows and creates a new
144
+ * class of mismatch), so the check is the cheap half instead: every segment from the root down to
145
+ * the target must be a real directory, verified with lstat right before deletion.
146
+ */
147
+ function linkedSegment(target, root) {
148
+ const normalizedRoot = normalizePath(root);
149
+ let current = path.resolve(target);
150
+ while (normalizePath(current) !== normalizedRoot) {
151
+ try {
152
+ if (fs.lstatSync(current).isSymbolicLink()) return current;
153
+ } catch {
154
+ return null; // Already gone: rmSync will report it honestly.
155
+ }
156
+ const parent = path.dirname(current);
157
+ if (parent === current) return null;
158
+ current = parent;
159
+ }
160
+ return null;
161
+ }
162
+
163
+ function currentTargetBytes(target, mode) {
164
+ if (mode === 'purge') {
165
+ // The inventory's recursive measurement is the same accounting used by projects and keeps
166
+ // the report honest when a purge contains nested files.
167
+ return recursiveSize(target);
168
+ }
169
+ try {
170
+ return fs.lstatSync(target).size;
171
+ } catch {
172
+ return null;
173
+ }
174
+ }
175
+
176
+ async function confirmAll(plan, args, options) {
177
+ const prompt = options.prompt || defaultPrompt;
178
+ for (const action of plan.actions) {
179
+ const question = action.mode === 'purge'
180
+ ? `Delete folder ${action.path}?`
181
+ : `Delete archived transport from ${action.project}/${action.run}?\n ${action.targets.join('\n ')}`;
182
+ let answer;
183
+ try {
184
+ answer = await prompt(question, action, options);
185
+ } catch (err) {
186
+ return { error: `codex-bridge prune: confirmation failed: ${err.message}` };
187
+ }
188
+ if (!accepted(answer)) return { declined: true };
189
+ }
190
+ return { declined: false };
191
+ }
192
+
193
+ function bytesAfterRemoval(values) {
194
+ return values.every((value) => Number.isFinite(value))
195
+ ? values.reduce((sum, value) => sum + value, 0)
196
+ : null;
197
+ }
198
+
199
+ async function execute(plan, options) {
200
+ const removed = [];
201
+ const sizes = [];
202
+ const failed = [];
203
+ for (const action of plan.actions) {
204
+ for (const target of action.targets) {
205
+ if (!targetInsideRoot(target, plan.root)) {
206
+ failed.push({ target, error: 'target is outside the configured runs root' });
207
+ continue;
208
+ }
209
+ const linked = linkedSegment(target, plan.root);
210
+ if (linked) {
211
+ failed.push({ target, error: `refusing to delete through a link: ${linked}` });
212
+ continue;
213
+ }
214
+ const bytes = currentTargetBytes(target, action.mode);
215
+ try {
216
+ fs.rmSync(target, { recursive: action.mode === 'purge', force: false });
217
+ removed.push(target);
218
+ sizes.push(bytes);
219
+ } catch (err) {
220
+ failed.push({ target, error: err.message });
221
+ }
222
+ }
223
+ }
224
+ const payload = {
225
+ ...basePayload(plan),
226
+ status: failed.length ? 'failed' : 'completed',
227
+ removed,
228
+ bytesFreed: bytesAfterRemoval(sizes),
229
+ failed,
230
+ };
231
+ return { payload, exitCode: failed.length ? 1 : 0 };
232
+ }
233
+
234
+ /** Runs prune with force, TTY, and confirmation guards around the shared read-only plan. */
235
+ export async function prune(argv = [], options = {}) {
236
+ const args = parsePruneArgs(argv);
237
+ if (args.error) {
238
+ const payload = { status: 'error', error: args.error };
239
+ return result(2, argv.includes('--json') ? output(payload, true) : payload.error);
240
+ }
241
+
242
+ const plan = prunePlan(args, options);
243
+ if (plan.error) {
244
+ const payload = { status: 'error', error: plan.error };
245
+ return result(1, output(payload, args.json));
246
+ }
247
+ if (!args.force) {
248
+ return result(0, args.json
249
+ ? JSON.stringify({ status: 'plan', ...basePayload(plan), command: commandText(args) }, null, 2)
250
+ : planLines(plan, args));
251
+ }
252
+ if (!plan.actions.length) {
253
+ const payload = { status: 'completed', ...basePayload(plan), removed: [], bytesFreed: 0, failed: [] };
254
+ return result(0, output(payload, args.json));
255
+ }
256
+ if (!tty(options)) {
257
+ const message = 'No TTY: refusing deletion because deletion is an operator action; no files were changed.';
258
+ const payload = { status: 'refused', ...basePayload(plan), error: message, removed: [], bytesFreed: 0 };
259
+ return result(1, output(payload, args.json));
260
+ }
261
+
262
+ const confirmation = await confirmAll(plan, args, options);
263
+ if (confirmation.error) {
264
+ const payload = { status: 'error', ...basePayload(plan), error: confirmation.error, removed: [], bytesFreed: 0 };
265
+ return result(1, output(payload, args.json));
266
+ }
267
+ if (confirmation.declined) {
268
+ const payload = { status: 'cancelled', ...basePayload(plan), removed: [], bytesFreed: 0 };
269
+ return result(1, output(payload, args.json));
270
+ }
271
+
272
+ const executed = await execute(plan, options);
273
+ return result(executed.exitCode, args.json
274
+ ? output(executed.payload, true)
275
+ : reportLines(executed.payload, executed.payload.status));
276
+ }
277
+
278
+ export { defaultPrompt };
package/cli/read.mjs ADDED
@@ -0,0 +1,103 @@
1
+ /** Renders a run's structured Codex transport as a readable event-by-event report. */
2
+ import { readEvents } from '../src/meta/events.mjs';
3
+ import { runsRoot } from '../src/runner/runs-root.mjs';
4
+ import { resolveRunFolder } from './run-lookup.mjs';
5
+
6
+ const result = (exitCode, output) => ({ exitCode, output });
7
+ const record = (value) => value && typeof value === 'object' && !Array.isArray(value);
8
+
9
+ const inline = (value, max = 300) => String(value ?? '').replace(/\s+/g, ' ').trim().slice(0, max);
10
+
11
+ function compactJson(value, max = 300) {
12
+ let text;
13
+ try {
14
+ text = JSON.stringify(value);
15
+ } catch {
16
+ text = String(value);
17
+ }
18
+ return inline(text, max) || '{}';
19
+ }
20
+
21
+ function itemSection(item) {
22
+ const type = typeof item?.type === 'string' && item.type ? item.type : 'unknown item';
23
+ if (type === 'agent_message') {
24
+ return `${type}\n${String(item.text ?? item.message ?? '')}`.trimEnd();
25
+ }
26
+ if (type === 'error') {
27
+ return `${type}\n${inline(item.message) || compactJson(item)}`;
28
+ }
29
+ const shortText = ['text', 'message', 'summary', 'output', 'command', 'result']
30
+ .map((key) => item?.[key])
31
+ .find((value) => typeof value === 'string' && value.trim());
32
+ return `${type}\n${shortText ? inline(shortText) : compactJson(item)}`;
33
+ }
34
+
35
+ function usageSection(usage) {
36
+ if (!record(usage)) return 'turn.completed\nUsage: unavailable';
37
+ const numbers = Object.entries(usage)
38
+ .map(([key, value]) => `${key}=${String(value)}`)
39
+ .join(', ');
40
+ return `turn.completed\nUsage: ${numbers || 'unavailable'}`;
41
+ }
42
+
43
+ function serviceSection(event) {
44
+ if (event.type === 'thread.started') {
45
+ return `${event.type} · Thread ID: ${event.thread_id ?? 'unknown'}`;
46
+ }
47
+ if (event.type === 'item.started' && typeof event.item?.type === 'string' && event.item.type) {
48
+ return `${event.type} · Item: ${event.item.type}`;
49
+ }
50
+ return event.type;
51
+ }
52
+
53
+ function transportSection(event, transportError) {
54
+ const nested = record(event.error) ? event.error : {};
55
+ const status = event.status ?? nested.status ?? nested.status_code
56
+ ?? event.codex_error_info?.http_status ?? nested.codex_error_info?.http_status;
57
+ const directMessage = typeof event.message === 'string'
58
+ ? event.message
59
+ : typeof nested.message === 'string' ? nested.message : '';
60
+ const details = transportError?.event === event ? transportError : null;
61
+ const message = details?.message || directMessage || details?.reason || 'unknown transport failure';
62
+ const resolvedStatus = details?.status ?? status ?? 'unknown';
63
+ return `${event.type}\nStatus: ${resolvedStatus}\nMessage: ${inline(message)}`;
64
+ }
65
+
66
+ function renderEvent(event, transportError) {
67
+ switch (event.type) {
68
+ case 'thread.started':
69
+ case 'turn.started':
70
+ case 'item.started':
71
+ return serviceSection(event);
72
+ case 'item.completed':
73
+ return itemSection(event.item);
74
+ case 'turn.completed':
75
+ return usageSection(event.usage);
76
+ case 'error':
77
+ case 'turn.failed':
78
+ return transportSection(event, transportError);
79
+ default:
80
+ // Unknown events stay visible in full: a future CLI event must never disappear silently.
81
+ return `${String(event.type || 'unknown event')}: ${compactJson(event, Infinity)}`;
82
+ }
83
+ }
84
+
85
+ export function read({ run, cwd = process.cwd(), runsRootPath = runsRoot() } = {}) {
86
+ const lookup = resolveRunFolder({ command: 'read', run, cwd, runsRootPath });
87
+ if (lookup.error) return result(1, lookup.error);
88
+
89
+ const eventData = readEvents(lookup.runDir);
90
+ // Two different failures, told apart because they send the operator to different places: a
91
+ // run from before the event stream existed has no file, while a run killed before the CLI
92
+ // spoke has an empty one. Reporting both as "predates this change" would hide the second.
93
+ if (!eventData.hasStream) {
94
+ return result(1, `Run ${lookup.runDir} has no events.jsonl; it predates the event stream.`);
95
+ }
96
+ if (!eventData.hasEvents) {
97
+ return result(
98
+ 1,
99
+ `Run ${lookup.runDir} has an empty events.jsonl: Codex wrote no event before the run ended.`,
100
+ );
101
+ }
102
+ return result(0, eventData.events.map((event) => renderEvent(event, eventData.transport_error)).join('\n\n'));
103
+ }
@@ -0,0 +1,59 @@
1
+ /** Resolves a run argument and confirms that it names an existing run directory. */
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { git } from '../src/runner/git-state.mjs';
5
+ import { resolveProjectRunsDir } from '../src/runner/project-dir.mjs';
6
+ import { runsRoot } from '../src/runner/runs-root.mjs';
7
+
8
+ function projectRoot(cwd) {
9
+ const result = git(cwd, ['rev-parse', '--show-toplevel']);
10
+ return result.status === 0 && result.stdout.trim() ? result.stdout.trim() : cwd;
11
+ }
12
+
13
+ function refusal(command, message) {
14
+ return { runDir: null, error: `codex-bridge ${command}: ${message}` };
15
+ }
16
+
17
+ /**
18
+ * Resolves the same bare-name lookup for every run command. Keeping the existence check here
19
+ * prevents a read command from reading a path that stop would reject, and the command parameter keeps
20
+ * malformed-input advice from naming the command the operator did not invoke.
21
+ */
22
+ export function resolveRunFolder({
23
+ command = 'stop',
24
+ run,
25
+ cwd = process.cwd(),
26
+ runsRootPath = runsRoot(),
27
+ } = {}) {
28
+ const commandName = String(command || 'stop');
29
+ const value = String(run ?? '').trim();
30
+ if (!value || value === '.' || value === '..') {
31
+ return refusal(commandName, `${commandName} requires a run folder (full path or bare folder name)`);
32
+ }
33
+
34
+ let runDir;
35
+ try {
36
+ if (path.isAbsolute(value)) runDir = path.resolve(value);
37
+ else {
38
+ if (path.dirname(value) !== '.') {
39
+ return refusal(commandName, 'a bare run folder name or a full path is required');
40
+ }
41
+ const projectRuns = resolveProjectRunsDir(runsRootPath, projectRoot(cwd), { create: false });
42
+ runDir = path.join(projectRuns.dir, value);
43
+ }
44
+ } catch (err) {
45
+ return refusal(commandName, err.message);
46
+ }
47
+
48
+ let isDirectory = false;
49
+ try {
50
+ isDirectory = fs.statSync(runDir).isDirectory();
51
+ } catch {}
52
+ if (!isDirectory) {
53
+ return {
54
+ runDir: null,
55
+ error: `Run folder not found: ${runDir}. Pass a full path or a bare run folder name from the current project's runs directory.`,
56
+ };
57
+ }
58
+ return { runDir, error: null };
59
+ }