@lyupro/codex-bridge 0.1.4 → 0.3.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.
- package/CHANGELOG.md +48 -0
- package/README.md +25 -4
- package/bin/codex-bridge.mjs +37 -0
- package/cli/doctor.mjs +20 -2
- package/cli/install.mjs +18 -4
- package/cli/manifest.mjs +3 -1
- package/cli/projects.mjs +92 -0
- package/cli/prune-args.mjs +133 -0
- package/cli/prune-plan.mjs +205 -0
- package/cli/prune.mjs +278 -0
- package/cli/read.mjs +103 -0
- package/cli/run-lookup.mjs +59 -0
- package/cli/runs-inventory.mjs +173 -0
- package/cli/stop.mjs +4 -37
- package/cli/sweep.mjs +144 -0
- package/cli/table.mjs +110 -0
- package/package.json +1 -1
- package/src/agents/codex-build.md +7 -6
- package/src/agents/codex-review.md +8 -8
- package/src/agents/codex-scout.md +7 -6
- package/src/commands/usage.md +4 -3
- package/src/heartbeat.mjs +80 -0
- package/src/hook-definitions.mjs +11 -0
- package/src/hooks/live-runs.mjs +13 -6
- package/src/hooks/prune-guard.mjs +62 -0
- package/src/hooks/reply-guard.mjs +2 -2
- package/src/hooks/worktree-lock.mjs +8 -1
- package/src/meta/deadline.mjs +19 -0
- package/src/meta/events.mjs +166 -0
- package/src/meta/reason.mjs +29 -0
- package/src/meta/reply.mjs +40 -7
- package/src/meta/run-state.mjs +25 -2
- package/src/meta/startup.mjs +21 -0
- package/src/meta/transport.mjs +35 -0
- package/src/meta/verdict.mjs +40 -42
- package/src/no-self-execution.mjs +18 -0
- package/src/retention.mjs +120 -0
- package/src/run-codex.mjs +3 -3
- package/src/run-config.json +5 -1
- package/src/run-config.mjs +54 -1
- package/src/runner/attach.mjs +1 -1
- package/src/runner/codex-cmd.mjs +158 -31
- package/src/runner/launcher.mjs +11 -0
- package/src/runner/worker.mjs +14 -3
- package/src/write-meta.mjs +31 -22
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,54 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.3.0] - 2026-08-07
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `codex-bridge sweep [<project>]` closes running records whose runner pid is dead, across the whole store or one project. It changes state but deletes no run folder or transport, never touches a live pid, and lists every stalled-but-live run with the exact `codex-bridge stop <run>` line that ends it. A dead record can therefore be cleared without paying for another run, while a live worker remains the sole writer until `stop` kills it first.
|
|
12
|
+
- Every new run folder carries a `heartbeat` file, and `status.json` carries `stdio_drained`. The heartbeat records movement from Codex output and the still-running child rather than a timer that would keep an orphan alive; `stdio_drained` says whether stdout/stderr closed normally or the bounded drain grace had to end the run.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- Hook liveness now requires `state: running`, a live runner pid and a heartbeat no older than five minutes. A stalled run therefore stops holding the worktree lock instead of holding it until the deadline; the refusal that remains — always about a working run — says how long ago it last made progress and prints `codex-bridge stop <run>` for taking the repository back early. Record cleanup and the second-writing-run check still judge by pid alone, so a stale but live run is released to the operator, not closed underneath its worker.
|
|
17
|
+
- The worker closes a run after `exit` with a bounded 30-second grace for stdio drain instead of waiting for `close`, which a grandchild can hold open forever. Healthy runs keep the event tail that carries their result and record `stdio_drained: true`; only the fallback records `false`.
|
|
18
|
+
- The three dispatcher prompts now open with one shared no-self-execution block rendered through `{{CODEX_NO_SELF_EXECUTION}}`. If `run-codex.mjs` does not start a run, the dispatcher must return `FAIL` and stop, and a reply with no run folder is blocked by the guard. The installed prompts change only when the host runs `codex-bridge update`; a host that does not update keeps the old prompts.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- A dispatcher that cannot start Codex can no longer read the code, write the files or fix the tests itself and claim a result. The guard also rejects a made-up reply with no `RUN=` or `ATTACH=` behind it: no run, no result.
|
|
23
|
+
|
|
24
|
+
## [0.2.0] - 2026-08-06
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- `codex-bridge projects` gives the run store a way to look at itself: one line per project — runs, weight, tokens spent, how many are running right now, when the last one started — and one line per run under a project name, plus `--json` for scripts. The store had grown to 12 projects, 152 runs and 80 MB with no command that could answer "what has Codex been working on"; the only way in was a file manager. The count of live runs is computed by the same module the reply guard and the worktree lock use, because a second detector of a live run sitting next to the first would drift from it silently.
|
|
29
|
+
- `codex-bridge prune` reclaims that space, and it is built to be hard to fire by accident: without `-f` it prints a plan and deletes nothing, `-f` still asks before each step, and with no terminal it refuses outright — deletion is an operator action, and the bypass flag that would undo this is deliberately absent. A gentle prune removes transport only (event streams, stderr, the archived `raw.log` of older runs) and leaves accounting, reports and worktree snapshots in place, so a task can still be continued and still be read after its megabytes are gone; `--purge` is what takes a folder whole, and it is refused together with `--all-projects`, because deleting a dozen projects with one command has no second line of defence. A live run is never a target — it is recognised by its process, not by a status file, since the file that would be deleted is the verdict being written right now.
|
|
30
|
+
- Run transport is dropped automatically from runs older than 30 days when a new run starts, and the dispatcher's reply says how much was freed: a rare event that goes unexplained sends someone looking for a fault. Automatic cleanup can only shed weight — the code to remove `meta.json` or a folder does not exist on that path at all, so it cannot be switched on by mistake — and `retention` in `run-config.json` changes the age or turns it off. Both `install` and `doctor` say out loud that it is on, because an installer that quietly starts deleting files is a surprise, not a convenience.
|
|
31
|
+
- A fourth hook refuses `codex-bridge prune` in an agent's shell command before the deleting process exists. Mentioning the command is not calling it: heredoc bodies and quoted text are stripped before the check, after the guard rejected the very commit that introduced it over a command name in the message. `git prune` and `npm prune` are left alone — a guard that breaks unrelated work is a guard that gets removed, and then it protects nothing.
|
|
32
|
+
- `codex-bridge read <run>` renders a run's events as text on demand. Readability is now something the package produces when asked rather than a file it keeps, and a rendering that is not stored cannot disagree with the facts.
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
|
|
36
|
+
- Runs go through `codex exec --json`, and `events.jsonl` is the single source of the quota signal, the token count, the session id and the failure reason. The stream is written by the CLI, not by the model, so a quotation out of a source file can no longer reach it. `tokens` is input plus output with the CLI's whole `usage` object beside it, and `session_id` comes from `thread.started` instead of a line matched in a log.
|
|
37
|
+
- The reason for a `FAIL` is taken from the run's own error event rather than from `stderr.log`. A live probe showed `stderr.log` is not empty even for a clean run — 488 bytes of execpolicy refusals, repository paths included — which made the reason a random blocked call instead of the real one. The transport branch keeps the reason the CLI itself gave with its status code: a `LIMIT` whose reason does not mention the quota is the same class of defect.
|
|
38
|
+
- A run that never began is one with no events *and* no stderr. Stderr with no events is a CLI that did not understand a flag — a different diagnosis, calling for a different action, and the two no longer collapse into one.
|
|
39
|
+
- `log_bytes` in `meta.json` is replaced by `events_bytes` and `stderr_bytes`.
|
|
40
|
+
|
|
41
|
+
### Removed
|
|
42
|
+
|
|
43
|
+
- `raw.log` is gone. Under `--json` it would have been a byte-for-byte copy of `events.jsonl` — a second source of truth with no role of its own — and the package already declared it ephemeral and safe to delete while resting the "abandoned at start" verdict on it. An artifact one is allowed to remove cannot be the ground of a verdict.
|
|
44
|
+
- The text detector of the quota signal is removed entirely rather than kept as a fallback. While the regular expressions live, so does the class of defect they caused.
|
|
45
|
+
|
|
46
|
+
### Fixed
|
|
47
|
+
|
|
48
|
+
- `LIMIT` is set only by an error event from the CLI carrying `status: 429` or an error type naming the limit. A `codex-build` run that had finished its work grepped the package's own test fixtures, printed a line where `ERROR` and `rate limit` stand together, and was closed as quota-exhausted — the work was complete and the suite was green, and the verdict threw it away.
|
|
49
|
+
- A run killed by its time budget says so. It records `stopped_on_deadline` and answers `FAIL — run stopped on its deadline`, instead of inventing a reason out of whatever text was nearest.
|
|
50
|
+
- A log file that cannot be written no longer leaves Codex orphaned. An unhandled stream error used to kill the worker while the CLI kept burning quota with nothing left to close it; now either stream failing takes down the process tree, so the run dies with its process rather than instead of it.
|
|
51
|
+
- A run that exited honestly is no longer marked as killed by its deadline. The timer judged by `close`, which a grandchild process can hold open long after the run is over; it now watches `exit`.
|
|
52
|
+
- A verdict is never shortened to fit the terminal. In a narrow window `running` was printed as `…nning`, which reads as a damaged run rather than one still in flight; a verdict is one word from a known set, like a number or a date, and the run name is what a narrow table gives up instead.
|
|
53
|
+
- The CLI's own warnings are no longer read as the model's complaint about the order. `codex-cli` announces a deprecated configuration key through the same event shape the model uses for a content error, before the turn even opens — so a failed run on such a host would have answered "`[features].codex_hooks` is deprecated" when asked why it failed. Only events after the first `turn.started` are the model's; a complaint about the order cannot predate the model's first thought.
|
|
54
|
+
|
|
7
55
|
## [0.1.4] - 2026-08-05
|
|
8
56
|
|
|
9
57
|
### Added
|
package/README.md
CHANGED
|
@@ -14,11 +14,14 @@ node bin/codex-bridge.mjs update # bring an installation up to the curren
|
|
|
14
14
|
node bin/codex-bridge.mjs doctor # what is installed, where it points, is codex alive
|
|
15
15
|
node bin/codex-bridge.mjs uninstall # removes exactly what was installed
|
|
16
16
|
node bin/codex-bridge.mjs stop <run> # close a hanging run by hand, without hunting for a pid
|
|
17
|
+
node bin/codex-bridge.mjs read <run> # read a run: its events rendered as text, on demand
|
|
18
|
+
node bin/codex-bridge.mjs projects # what the run store holds: projects, runs, weight, tokens
|
|
19
|
+
node bin/codex-bridge.mjs prune <project> # reclaim disk space, transport only unless told otherwise
|
|
20
|
+
node bin/codex-bridge.mjs sweep [<project>] # close running records whose runner is gone
|
|
17
21
|
```
|
|
18
22
|
|
|
19
23
|
`install` copies the package into the host's `agents/codex/`, its slash commands into
|
|
20
|
-
`commands/codex/`, and registers
|
|
21
|
-
`settings.json` — **merged**, so hooks that
|
|
24
|
+
`commands/codex/`, and registers its four hooks in `settings.json` — **merged**, so hooks that
|
|
22
25
|
are already there survive, and the file is backed up before every write. Run it twice and the
|
|
23
26
|
second run does nothing. `--dry-run` prints the plan and touches nothing.
|
|
24
27
|
|
|
@@ -42,6 +45,22 @@ Run artifacts live in `codex-runs/<project>/`, one folder per repository, marked
|
|
|
42
45
|
different `api` — therefore get `api` and `api-2` instead of one mixed history. `doctor` prints
|
|
43
46
|
which folder the current repository writes to.
|
|
44
47
|
|
|
48
|
+
`projects` reads that store back: without an argument, one line per project — runs, weight, tokens
|
|
49
|
+
spent, how many are running right now; with a project name, one line per run. `prune` is the only
|
|
50
|
+
command in the package that destroys data, and it is built to be hard to fire by accident: without
|
|
51
|
+
`-f` it prints a plan and deletes nothing, `-f` still asks before each step, and with no terminal
|
|
52
|
+
it refuses outright, because deletion is an operator action and there is no flag around it.
|
|
53
|
+
By default it removes transport only — event streams and stderr, the megabytes — and leaves
|
|
54
|
+
accounting, reports and worktree snapshots alone; `--purge` is what takes a folder whole.
|
|
55
|
+
The same transport is dropped automatically from runs older than 30 days when a new run starts,
|
|
56
|
+
which the reply says out loud; `retention` in `run-config.json` changes the age or turns it off.
|
|
57
|
+
|
|
58
|
+
`sweep` is housekeeping for records left by a dead runner. With no project it scans the whole
|
|
59
|
+
store; with one project it limits the scan to that folder. It uses the same dead-pid decision as
|
|
60
|
+
start-of-run cleanup, never closes a live pid even when its heartbeat is stale, and never deletes
|
|
61
|
+
run artifacts. A stale live-pid record is named with the exact `codex-bridge stop <run>` command
|
|
62
|
+
needed to close it.
|
|
63
|
+
|
|
45
64
|
## What it gives you
|
|
46
65
|
|
|
47
66
|
- **Three dispatcher agents** for Claude Code — reconnaissance (read-only), implementation
|
|
@@ -52,8 +71,10 @@ which folder the current repository writes to.
|
|
|
52
71
|
- **A verdict that checks the report against the worktree.** The agent's own account of what
|
|
53
72
|
it changed is compared with what git says actually changed, and a mismatch is a failed run
|
|
54
73
|
rather than a nicer-sounding summary.
|
|
55
|
-
- **
|
|
56
|
-
the run
|
|
74
|
+
- **Four hooks that hold the rules the prompts only ask for**: a reply is refused when it does not
|
|
75
|
+
match the recorded state of the run, a dispatcher call is refused when it carries no order id, a
|
|
76
|
+
file edit is refused inside a repository a live run holds, and deletion of the run store is
|
|
77
|
+
refused when an agent is the one asking.
|
|
57
78
|
|
|
58
79
|
## Requirements
|
|
59
80
|
|
package/bin/codex-bridge.mjs
CHANGED
|
@@ -5,8 +5,12 @@ import { fileURLToPath } from 'node:url';
|
|
|
5
5
|
import { diagnose, renderDoctor } from '../cli/doctor.mjs';
|
|
6
6
|
import { resolveHost } from '../cli/hosts.mjs';
|
|
7
7
|
import { install } from '../cli/install.mjs';
|
|
8
|
+
import { projects } from '../cli/projects.mjs';
|
|
9
|
+
import { read } from '../cli/read.mjs';
|
|
8
10
|
import { packageInfo } from '../cli/manifest.mjs';
|
|
11
|
+
import { prune } from '../cli/prune.mjs';
|
|
9
12
|
import { stop } from '../cli/stop.mjs';
|
|
13
|
+
import { sweep } from '../cli/sweep.mjs';
|
|
10
14
|
import { uninstall } from '../cli/uninstall.mjs';
|
|
11
15
|
import { update } from '../cli/update.mjs';
|
|
12
16
|
|
|
@@ -17,6 +21,11 @@ Usage:
|
|
|
17
21
|
codex-bridge update [--scope user|project] [--host <path>] [--dry-run] [--force]
|
|
18
22
|
codex-bridge uninstall [--scope user|project] [--host <path>] [--dry-run]
|
|
19
23
|
codex-bridge doctor [--scope user|project] [--host <path>]
|
|
24
|
+
codex-bridge projects [<name>] [--json]
|
|
25
|
+
codex-bridge prune <project> [<run>] [--purge] [--older-than <age>] [-f] [--json]
|
|
26
|
+
codex-bridge prune --all-projects [--older-than <age>] [-f] [--json]
|
|
27
|
+
codex-bridge sweep [<project>]
|
|
28
|
+
codex-bridge read <run>
|
|
20
29
|
codex-bridge stop <run>
|
|
21
30
|
codex-bridge --help
|
|
22
31
|
codex-bridge --version
|
|
@@ -26,6 +35,10 @@ Commands:
|
|
|
26
35
|
update Update a recorded codex-bridge installation
|
|
27
36
|
uninstall Remove installed files while preserving run artifacts
|
|
28
37
|
doctor Diagnose the selected Claude Code host
|
|
38
|
+
projects List projects or runs from the run store
|
|
39
|
+
prune Remove archived transport, or purge selected run folders
|
|
40
|
+
sweep Close running records whose runner is gone
|
|
41
|
+
read Render a run's structured event stream
|
|
29
42
|
stop Stop a running Codex run and record FAIL`;
|
|
30
43
|
|
|
31
44
|
function commandOptions(command, argv) {
|
|
@@ -76,6 +89,30 @@ export async function main(argv, io = console) {
|
|
|
76
89
|
io.log(result.output);
|
|
77
90
|
return result.exitCode;
|
|
78
91
|
}
|
|
92
|
+
if (command === 'read') {
|
|
93
|
+
if (rest.length !== 1) {
|
|
94
|
+
io.error('codex-bridge read requires exactly one run folder (full path or bare name).');
|
|
95
|
+
return 2;
|
|
96
|
+
}
|
|
97
|
+
const result = read({ run: rest[0] });
|
|
98
|
+
io.log(result.output);
|
|
99
|
+
return result.exitCode;
|
|
100
|
+
}
|
|
101
|
+
if (command === 'projects') {
|
|
102
|
+
const result = projects(rest);
|
|
103
|
+
io.log(result.output);
|
|
104
|
+
return result.exitCode;
|
|
105
|
+
}
|
|
106
|
+
if (command === 'prune') {
|
|
107
|
+
const result = await prune(rest);
|
|
108
|
+
io.log(result.output);
|
|
109
|
+
return result.exitCode;
|
|
110
|
+
}
|
|
111
|
+
if (command === 'sweep') {
|
|
112
|
+
const result = sweep(rest);
|
|
113
|
+
io.log(result.output);
|
|
114
|
+
return result.exitCode;
|
|
115
|
+
}
|
|
79
116
|
if (command === 'install' || command === 'update' || command === 'uninstall') {
|
|
80
117
|
const options = commandOptions(command, rest);
|
|
81
118
|
const host = resolveHost(options);
|
package/cli/doctor.mjs
CHANGED
|
@@ -10,9 +10,13 @@ import {
|
|
|
10
10
|
} from './manifest.mjs';
|
|
11
11
|
import { commandFor, inspectHook } from './settings-merge.mjs';
|
|
12
12
|
import { readRulesRegistry } from './rules-owners.mjs';
|
|
13
|
+
import { readRunConfig, retentionNotice } from '../src/run-config.mjs';
|
|
13
14
|
import { runsRoot } from '../src/runner/runs-root.mjs';
|
|
14
15
|
import { resolveProjectRunsDir } from '../src/runner/project-dir.mjs';
|
|
15
16
|
|
|
17
|
+
const WARNING = '\u001b[33m';
|
|
18
|
+
const RESET = '\u001b[0m';
|
|
19
|
+
|
|
16
20
|
async function exists(target) {
|
|
17
21
|
try {
|
|
18
22
|
await fs.access(target);
|
|
@@ -34,6 +38,15 @@ function check(key, status, value) {
|
|
|
34
38
|
return { key, status, value };
|
|
35
39
|
}
|
|
36
40
|
|
|
41
|
+
function retentionCheck(host) {
|
|
42
|
+
try {
|
|
43
|
+
const notice = retentionNotice(readRunConfig(path.join(host.agentsDir, 'run-config.json')));
|
|
44
|
+
return check('retention', notice.enabled ? 'warn' : 'ok', notice.text);
|
|
45
|
+
} catch (err) {
|
|
46
|
+
return check('retention', 'fail', `invalid configuration: ${err.message}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
37
50
|
/**
|
|
38
51
|
* The runner asks git for the repository root before it picks a runs folder, so doctor has to
|
|
39
52
|
* ask the same question: run from `src/runner`, a plain cwd would name the folder `runner` and
|
|
@@ -158,6 +171,8 @@ export async function diagnose({ host, codexProbe = probeCodex, currentPackage }
|
|
|
158
171
|
const rules = await rulesCheck(host, record);
|
|
159
172
|
checks.push(rules);
|
|
160
173
|
checks.push(...await hookChecks(host, record));
|
|
174
|
+
const retention = retentionCheck(host);
|
|
175
|
+
checks.push(retention);
|
|
161
176
|
|
|
162
177
|
const codex = codexProbe();
|
|
163
178
|
checks.push(check('codex', codex.available ? 'ok' : 'warn', codex.value || 'available'));
|
|
@@ -169,7 +184,7 @@ export async function diagnose({ host, codexProbe = probeCodex, currentPackage }
|
|
|
169
184
|
|
|
170
185
|
return {
|
|
171
186
|
exitCode: !record || recordBroken || missingFiles.length || rules.status === 'fail'
|
|
172
|
-
|| projectRuns.status === 'fail' ? 1 : 0,
|
|
187
|
+
|| retention.status === 'fail' || projectRuns.status === 'fail' ? 1 : 0,
|
|
173
188
|
checks,
|
|
174
189
|
record,
|
|
175
190
|
missingFiles,
|
|
@@ -177,5 +192,8 @@ export async function diagnose({ host, codexProbe = probeCodex, currentPackage }
|
|
|
177
192
|
}
|
|
178
193
|
|
|
179
194
|
export function renderDoctor(result) {
|
|
180
|
-
return result.checks.map(({ key, status, value }) =>
|
|
195
|
+
return result.checks.map(({ key, status, value }) => {
|
|
196
|
+
const rendered = `[${status}] ${key}: ${value}`;
|
|
197
|
+
return key === 'retention' && status === 'warn' ? `${WARNING}${rendered}${RESET}` : rendered;
|
|
198
|
+
}).join('\n');
|
|
181
199
|
}
|
package/cli/install.mjs
CHANGED
|
@@ -16,6 +16,10 @@ import {
|
|
|
16
16
|
import { copyPlannedFile, targetMatches } from './copy.mjs';
|
|
17
17
|
import { commandFor, inspectHook, mergeHook } from './settings-merge.mjs';
|
|
18
18
|
import { addRulesOwner, readRulesRegistry } from './rules-owners.mjs';
|
|
19
|
+
import { readRunConfig, retentionNotice } from '../src/run-config.mjs';
|
|
20
|
+
|
|
21
|
+
const WARNING = '\u001b[33m';
|
|
22
|
+
const RESET = '\u001b[0m';
|
|
19
23
|
|
|
20
24
|
async function targetExists(target) {
|
|
21
25
|
try {
|
|
@@ -45,9 +49,19 @@ function recordHasHooks(record, targets) {
|
|
|
45
49
|
hook.event === definition.event && hook.path === relative));
|
|
46
50
|
}
|
|
47
51
|
|
|
52
|
+
function retentionLine(host) {
|
|
53
|
+
const notice = retentionNotice(readRunConfig(path.join(host.agentsDir, 'run-config.json')));
|
|
54
|
+
return notice.enabled ? `${WARNING}${notice.text}${RESET}` : notice.text;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function retentionOutput(line, output) {
|
|
58
|
+
return `${output}\n${line}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
48
61
|
export async function install({ host, dryRun = false, force = false, packageRoot } = {}) {
|
|
49
62
|
// Validate the shared registry before writes; package removal on a broken registry left the host without its watchdog.
|
|
50
63
|
await readRulesRegistry(host);
|
|
64
|
+
const configuredRetentionLine = retentionLine(host);
|
|
51
65
|
const plan = await buildInstallPlan(host, packageRoot);
|
|
52
66
|
const rule = { ...rulesPlan(host, packageRoot), processing: 'copy' };
|
|
53
67
|
const currentPackage = await packageInfo(packageRoot);
|
|
@@ -83,7 +97,7 @@ export async function install({ host, dryRun = false, force = false, packageRoot
|
|
|
83
97
|
const files = conflicts.map(({ item }) => ` ${item.relativeToHost || item.name}`).join('\n');
|
|
84
98
|
return {
|
|
85
99
|
exitCode: 1,
|
|
86
|
-
output: `Conflicting files:\n${files}\nRun install again with --force to overwrite them
|
|
100
|
+
output: retentionOutput(configuredRetentionLine, `Conflicting files:\n${files}\nRun install again with --force to overwrite them.`),
|
|
87
101
|
};
|
|
88
102
|
}
|
|
89
103
|
|
|
@@ -95,7 +109,7 @@ export async function install({ host, dryRun = false, force = false, packageRoot
|
|
|
95
109
|
if (!changedFiles.length && !changedRule && inspectedHooks.every((state) => state.present)
|
|
96
110
|
&& recordHasHooks(record, targets) && sameRecord) {
|
|
97
111
|
if (!dryRun) await addRulesOwner(host);
|
|
98
|
-
return { exitCode: 0, output: 'codex-bridge is already installed; nothing to do.' };
|
|
112
|
+
return { exitCode: 0, output: retentionOutput(configuredRetentionLine, 'codex-bridge is already installed; nothing to do.') };
|
|
99
113
|
}
|
|
100
114
|
|
|
101
115
|
if (dryRun) {
|
|
@@ -111,7 +125,7 @@ export async function install({ host, dryRun = false, force = false, packageRoot
|
|
|
111
125
|
: `Would register ${definition.event} hook for matcher ${definition.matcher}.`);
|
|
112
126
|
});
|
|
113
127
|
lines.push('Would write installation record.');
|
|
114
|
-
return { exitCode: 0, output: lines.join('\n') };
|
|
128
|
+
return { exitCode: 0, output: retentionOutput(configuredRetentionLine, lines.join('\n')) };
|
|
115
129
|
}
|
|
116
130
|
|
|
117
131
|
// Claim ownership of the shared rules file before writing anything. Claiming it last meant a
|
|
@@ -146,6 +160,6 @@ export async function install({ host, dryRun = false, force = false, packageRoot
|
|
|
146
160
|
});
|
|
147
161
|
return {
|
|
148
162
|
exitCode: 0,
|
|
149
|
-
output: `Installed ${plan.length + 1} files and registered the ${targets.map(({ definition }) => definition.event).join(' and ')} hooks
|
|
163
|
+
output: retentionOutput(configuredRetentionLine, `Installed ${plan.length + 1} files and registered the ${targets.map(({ definition }) => definition.event).join(' and ')} hooks.`),
|
|
150
164
|
};
|
|
151
165
|
}
|
package/cli/manifest.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
renderRequiredInputSummary,
|
|
11
11
|
renderRequiredInputs,
|
|
12
12
|
} from '../src/required-inputs.mjs';
|
|
13
|
+
import { renderNoSelfExecution } from '../src/no-self-execution.mjs';
|
|
13
14
|
|
|
14
15
|
export { HOOK_DEFINITIONS };
|
|
15
16
|
|
|
@@ -42,7 +43,8 @@ export function replacePlaceholders(content, agentsDir) {
|
|
|
42
43
|
return content
|
|
43
44
|
.replaceAll('{{CODEX_BRIDGE_DIR}}', posix(path.resolve(agentsDir)))
|
|
44
45
|
.replaceAll('{{CODEX_REQUIRED_INPUTS_SUMMARY}}', renderRequiredInputSummary(agentType))
|
|
45
|
-
.replaceAll('{{CODEX_REQUIRED_INPUTS}}', renderRequiredInputs(agentType))
|
|
46
|
+
.replaceAll('{{CODEX_REQUIRED_INPUTS}}', renderRequiredInputs(agentType))
|
|
47
|
+
.replaceAll('{{CODEX_NO_SELF_EXECUTION}}', renderNoSelfExecution());
|
|
46
48
|
}
|
|
47
49
|
|
|
48
50
|
export async function packageInfo(packageRoot = PACKAGE_ROOT) {
|
package/cli/projects.mjs
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/** Implements the projects command. */
|
|
2
|
+
import { listProjectRuns, listProjects } from './runs-inventory.mjs';
|
|
3
|
+
import { renderTable } from './table.mjs';
|
|
4
|
+
|
|
5
|
+
// The table renders whatever it is given; how a byte count or a timestamp reads is this command's
|
|
6
|
+
// business. Raw bytes and ISO milliseconds are technically exact and practically unreadable — an
|
|
7
|
+
// inventory exists to be scanned by eye, and 8468429 has to be counted digit by digit.
|
|
8
|
+
const SIZE_UNITS = [['GB', 1024 ** 3], ['MB', 1024 ** 2], ['KB', 1024]];
|
|
9
|
+
|
|
10
|
+
function formatSize(bytes) {
|
|
11
|
+
if (typeof bytes !== 'number' || !Number.isFinite(bytes)) return null;
|
|
12
|
+
for (const [unit, scale] of SIZE_UNITS) {
|
|
13
|
+
if (bytes >= scale) return `${(bytes / scale).toFixed(bytes >= 10 * scale ? 0 : 1)} ${unit}`;
|
|
14
|
+
}
|
|
15
|
+
return `${bytes} B`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Both timestamp shapes in the store: an ISO stamp from artifacts, a folder name otherwise. */
|
|
19
|
+
function formatStamp(value) {
|
|
20
|
+
if (typeof value !== 'string' || !value.trim()) return null;
|
|
21
|
+
const iso = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})/.exec(value);
|
|
22
|
+
if (iso) return `${iso[1]} ${iso[2]}`;
|
|
23
|
+
const folder = /^(\d{4}-\d{2}-\d{2})_(\d{2})(\d{2})/.exec(value);
|
|
24
|
+
return folder ? `${folder[1]} ${folder[2]}:${folder[3]}` : value;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const PROJECT_COLUMNS = [
|
|
28
|
+
{ key: 'project', header: 'project', truncate: 'start' },
|
|
29
|
+
{ key: 'runs', header: 'runs', kind: 'number' },
|
|
30
|
+
{ key: 'size', header: 'size', kind: 'number', value: (row) => formatSize(row.size) },
|
|
31
|
+
{ key: 'totalTokens', header: 'total tokens', kind: 'number' },
|
|
32
|
+
{ key: 'liveNow', header: 'live now', kind: 'number' },
|
|
33
|
+
{ key: 'lastRun', header: 'last run', kind: 'date', value: (row) => formatStamp(row.lastRun) },
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
// The verdict is never shortened, for the same reason numbers and dates are not: it is one whole
|
|
37
|
+
// word from a known set, and half of it is not half an answer. A narrow terminal turned `running`
|
|
38
|
+
// into `…nning`, which reads as damage rather than as a run in flight. The agent column keeps the
|
|
39
|
+
// tail because that is exactly what tells `codex-build` from `codex-scout`.
|
|
40
|
+
const RUN_COLUMNS = [
|
|
41
|
+
{ key: 'run', header: 'run', truncate: 'start' },
|
|
42
|
+
{ key: 'agent', header: 'agent', truncate: 'start' },
|
|
43
|
+
{ key: 'verdict', header: 'verdict', fixed: true },
|
|
44
|
+
{ key: 'tokens', header: 'tokens', kind: 'number' },
|
|
45
|
+
{ key: 'size', header: 'size', kind: 'number', value: (row) => formatSize(row.size) },
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
function parseArgs(args) {
|
|
49
|
+
const positional = [];
|
|
50
|
+
let json = false;
|
|
51
|
+
for (const arg of args) {
|
|
52
|
+
if (arg === '--json') {
|
|
53
|
+
json = true;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (arg.startsWith('-')) return { error: `codex-bridge projects: unknown option "${arg}"` };
|
|
57
|
+
positional.push(arg);
|
|
58
|
+
}
|
|
59
|
+
if (positional.length > 1) {
|
|
60
|
+
return { error: 'codex-bridge projects accepts at most one project name.' };
|
|
61
|
+
}
|
|
62
|
+
return { json, projectName: positional[0] || null };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function output(rows, columns, options) {
|
|
66
|
+
if (options.json) return JSON.stringify(rows, null, 2);
|
|
67
|
+
return renderTable(columns, rows, options.terminalWidth);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Lists projects or runs for one project. */
|
|
71
|
+
export function projects(argv = [], options = {}) {
|
|
72
|
+
const parsed = parseArgs(argv);
|
|
73
|
+
if (parsed.error) return { exitCode: 2, output: parsed.error };
|
|
74
|
+
|
|
75
|
+
const { projectName } = parsed;
|
|
76
|
+
const root = options.runsRootPath;
|
|
77
|
+
const renderOptions = { json: parsed.json, terminalWidth: options.terminalWidth };
|
|
78
|
+
|
|
79
|
+
if (projectName) {
|
|
80
|
+
const rows = listProjectRuns(root, projectName);
|
|
81
|
+
if (rows === null) {
|
|
82
|
+
return {
|
|
83
|
+
exitCode: 1,
|
|
84
|
+
output: `codex-bridge projects: unknown project "${projectName}"`,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
return { exitCode: 0, output: output(rows, RUN_COLUMNS, renderOptions) };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const rows = listProjects(root);
|
|
91
|
+
return { exitCode: 0, output: output(rows, PROJECT_COLUMNS, renderOptions) };
|
|
92
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/** Parses prune scopes and refuses combinations that could widen destructive reach. */
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
const DURATION = /^(\d+)([dh])$/;
|
|
5
|
+
const DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
6
|
+
|
|
7
|
+
function error(message) {
|
|
8
|
+
return { error: `codex-bridge prune: ${message}` };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function validCalendarDate(value) {
|
|
12
|
+
const match = DATE.exec(value);
|
|
13
|
+
if (!match) return false;
|
|
14
|
+
const [, year, month, day] = match;
|
|
15
|
+
const timestamp = Date.UTC(Number(year), Number(month) - 1, Number(day));
|
|
16
|
+
const date = new Date(timestamp);
|
|
17
|
+
return date.getUTCFullYear() === Number(year)
|
|
18
|
+
&& date.getUTCMonth() === Number(month) - 1
|
|
19
|
+
&& date.getUTCDate() === Number(day);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Parses 30d, 12h, or a calendar date without consulting the filesystem. */
|
|
23
|
+
export function parseOlderThan(value) {
|
|
24
|
+
const text = String(value ?? '').trim();
|
|
25
|
+
const duration = DURATION.exec(text);
|
|
26
|
+
if (duration) {
|
|
27
|
+
const amount = Number(duration[1]);
|
|
28
|
+
if (amount > 0 && Number.isSafeInteger(amount)) {
|
|
29
|
+
return { kind: 'duration', amount, unit: duration[2] };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (validCalendarDate(text)) return { kind: 'date', date: text };
|
|
33
|
+
return { error: `--older-than must be a positive duration like 30d or 12h, or an exact date YYYY-MM-DD; got "${text}"` };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function validateName(label, value) {
|
|
37
|
+
if (!value || value === '.' || value === '..' || path.isAbsolute(value) || path.dirname(value) !== '.') {
|
|
38
|
+
return error(`${label} must be a bare folder name`);
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Parses prune's positional scopes and safety flags. */
|
|
44
|
+
export function parsePruneArgs(argv = []) {
|
|
45
|
+
const positional = [];
|
|
46
|
+
let allProjects = false;
|
|
47
|
+
let purge = false;
|
|
48
|
+
let force = false;
|
|
49
|
+
let json = false;
|
|
50
|
+
let olderThan = null;
|
|
51
|
+
let olderThanExplicit = false;
|
|
52
|
+
|
|
53
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
54
|
+
const arg = argv[index];
|
|
55
|
+
if (arg === '--all-projects') {
|
|
56
|
+
allProjects = true;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (arg === '--purge') {
|
|
60
|
+
purge = true;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (arg === '--force' || arg === '-f') {
|
|
64
|
+
force = true;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (arg === '--json') {
|
|
68
|
+
json = true;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (arg === '--older-than' || arg === '--older-than=') {
|
|
72
|
+
const value = argv[index + 1];
|
|
73
|
+
if (value === undefined || value.startsWith('-')) return error('--older-than requires a value');
|
|
74
|
+
const parsed = parseOlderThan(value);
|
|
75
|
+
if (parsed.error) return error(parsed.error.replace(/^--older-than\s*/, '--older-than '));
|
|
76
|
+
olderThan = parsed;
|
|
77
|
+
olderThanExplicit = true;
|
|
78
|
+
index += 1;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (arg.startsWith('--older-than=')) {
|
|
82
|
+
const value = arg.slice('--older-than='.length);
|
|
83
|
+
if (!value) return error('--older-than requires a value');
|
|
84
|
+
const parsed = parseOlderThan(value);
|
|
85
|
+
if (parsed.error) return error(parsed.error.replace(/^--older-than\s*/, '--older-than '));
|
|
86
|
+
olderThan = parsed;
|
|
87
|
+
olderThanExplicit = true;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (arg.startsWith('-')) return error(`unknown option "${arg}"`);
|
|
91
|
+
positional.push(arg);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (allProjects && purge) {
|
|
95
|
+
return error('--all-projects cannot be combined with --purge; all-projects is gentle only');
|
|
96
|
+
}
|
|
97
|
+
if (allProjects && positional.length) {
|
|
98
|
+
return error('--all-projects does not accept a project or run name');
|
|
99
|
+
}
|
|
100
|
+
if (!allProjects && (positional.length < 1 || positional.length > 2)) {
|
|
101
|
+
return error('expected <project>, optional <run>, or --all-projects');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const projectName = positional[0] || null;
|
|
105
|
+
const runName = positional[1] || null;
|
|
106
|
+
if (projectName) {
|
|
107
|
+
const invalid = validateName('project', projectName);
|
|
108
|
+
if (invalid) return invalid;
|
|
109
|
+
}
|
|
110
|
+
if (runName) {
|
|
111
|
+
const invalid = validateName('run', runName);
|
|
112
|
+
if (invalid) return invalid;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// The 30-day default belongs to the gentle sweep, which searches history for weight to shed.
|
|
116
|
+
// A purge is never implicitly aged: `prune sbx2 --purge` means that folder, now. Defaulting it
|
|
117
|
+
// made the plan's own fourth scenario unreachable — a young project answered "nothing to
|
|
118
|
+
// remove", which reads as a broken command, not as a refusal.
|
|
119
|
+
if (!olderThan && !purge && (allProjects || !runName)) {
|
|
120
|
+
olderThan = { kind: 'duration', amount: 30, unit: 'd' };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
allProjects,
|
|
125
|
+
projectName,
|
|
126
|
+
runName,
|
|
127
|
+
purge,
|
|
128
|
+
force,
|
|
129
|
+
json,
|
|
130
|
+
olderThan,
|
|
131
|
+
olderThanExplicit,
|
|
132
|
+
};
|
|
133
|
+
}
|