@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.
- package/CHANGELOG.md +44 -0
- package/README.md +18 -4
- package/bin/codex-bridge.mjs +29 -0
- package/cli/doctor.mjs +26 -3
- package/cli/install.mjs +18 -4
- package/cli/manifest.mjs +6 -4
- 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/settings-merge.mjs +18 -4
- package/cli/stop.mjs +4 -37
- package/cli/table.mjs +110 -0
- package/package.json +1 -1
- package/src/agents/codex-build.md +12 -10
- package/src/agents/codex-review.md +11 -5
- package/src/agents/codex-scout.md +11 -10
- package/src/commands/usage.md +4 -3
- package/src/hook-definitions.mjs +22 -0
- package/src/hooks/live-runs.mjs +98 -0
- package/src/hooks/prune-guard.mjs +62 -0
- package/src/hooks/reply-guard.mjs +45 -22
- package/src/hooks/worktree-lock.mjs +70 -0
- 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 +7 -1
- package/src/meta/startup.mjs +21 -0
- package/src/meta/transport.mjs +35 -0
- package/src/meta/verdict.mjs +40 -42
- package/src/required-inputs.mjs +49 -6
- 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 +119 -31
- package/src/runner/continuation.mjs +107 -0
- package/src/runner/launcher.mjs +23 -40
- package/src/runner/worker.mjs +11 -3
- package/src/write-meta.mjs +31 -22
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,50 @@ 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.2.0] - 2026-08-06
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `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.
|
|
12
|
+
- `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.
|
|
13
|
+
- 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.
|
|
14
|
+
- 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.
|
|
15
|
+
- `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.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- 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.
|
|
20
|
+
- 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.
|
|
21
|
+
- 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.
|
|
22
|
+
- `log_bytes` in `meta.json` is replaced by `events_bytes` and `stderr_bytes`.
|
|
23
|
+
|
|
24
|
+
### Removed
|
|
25
|
+
|
|
26
|
+
- `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.
|
|
27
|
+
- 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.
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- `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.
|
|
32
|
+
- 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.
|
|
33
|
+
- 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.
|
|
34
|
+
- 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`.
|
|
35
|
+
- 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.
|
|
36
|
+
- 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.
|
|
37
|
+
|
|
38
|
+
## [0.1.4] - 2026-08-05
|
|
39
|
+
|
|
40
|
+
### Added
|
|
41
|
+
|
|
42
|
+
- A continuation has to prove the orchestrator ordered it. `--continue` is refused unless the task text carries a `continue:` grant naming the run being continued and why, and the refusal costs nothing: no run folder is created and Codex is never started. The grant is single-use by construction rather than by a counter — continuing the last run of a chain appends a later one, so the same line stops matching by itself. Until this release a dispatcher could decide on a second pass alone: one did, spent 75 691 tokens of the Codex subscription and created a module the order never asked for.
|
|
43
|
+
- A reply that stays silent about a live `codex-build` run of the same project is blocked. The guard reads the project's runs folder from disk instead of trusting the paths the reply chose to mention, so a second run can no longer be left working in the background while the verdict of the first is handed back. Runs of `codex-scout` and `codex-review` are not counted: they hold a read-only sandbox, and blocking a reply over one would spend the guard's budget on a run that touches nothing.
|
|
44
|
+
- A `PreToolUse` lock refuses file edits inside a repository held by a live `codex-build` run. Knowing that a run is live is not the same as being unable to disturb it — edits made during that window land in the runner's before/after snapshot and fail an honest run for work it never did. An edit made through `Bash` still walks past the lock; parsing a command line is not something a guard can do reliably.
|
|
45
|
+
|
|
46
|
+
### Fixed
|
|
47
|
+
|
|
48
|
+
- `doctor` names the file each hook was registered for. With two hooks on one event it matched the installation record by event alone and reported the worktree lock's matcher as pointing at `order-gate.mjs` — the one line where an operator can check what is installed, showing the wrong file.
|
|
49
|
+
- Hook presence is decided by this package's own command rather than by the matcher it currently sits under. The matcher is generated from the list of host tool names and grows whenever a spelling is added, so a host installed under the previous matcher would collect a duplicate hook on update and keep a hook pointing at a deleted file after uninstall.
|
|
50
|
+
|
|
7
51
|
## [0.1.3] - 2026-08-05
|
|
8
52
|
|
|
9
53
|
### Added
|
package/README.md
CHANGED
|
@@ -14,11 +14,13 @@ 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
|
|
17
20
|
```
|
|
18
21
|
|
|
19
22
|
`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
|
|
23
|
+
`commands/codex/`, and registers its four hooks in `settings.json` — **merged**, so hooks that
|
|
22
24
|
are already there survive, and the file is backed up before every write. Run it twice and the
|
|
23
25
|
second run does nothing. `--dry-run` prints the plan and touches nothing.
|
|
24
26
|
|
|
@@ -42,6 +44,16 @@ Run artifacts live in `codex-runs/<project>/`, one folder per repository, marked
|
|
|
42
44
|
different `api` — therefore get `api` and `api-2` instead of one mixed history. `doctor` prints
|
|
43
45
|
which folder the current repository writes to.
|
|
44
46
|
|
|
47
|
+
`projects` reads that store back: without an argument, one line per project — runs, weight, tokens
|
|
48
|
+
spent, how many are running right now; with a project name, one line per run. `prune` is the only
|
|
49
|
+
command in the package that destroys data, and it is built to be hard to fire by accident: without
|
|
50
|
+
`-f` it prints a plan and deletes nothing, `-f` still asks before each step, and with no terminal
|
|
51
|
+
it refuses outright, because deletion is an operator action and there is no flag around it.
|
|
52
|
+
By default it removes transport only — event streams and stderr, the megabytes — and leaves
|
|
53
|
+
accounting, reports and worktree snapshots alone; `--purge` is what takes a folder whole.
|
|
54
|
+
The same transport is dropped automatically from runs older than 30 days when a new run starts,
|
|
55
|
+
which the reply says out loud; `retention` in `run-config.json` changes the age or turns it off.
|
|
56
|
+
|
|
45
57
|
## What it gives you
|
|
46
58
|
|
|
47
59
|
- **Three dispatcher agents** for Claude Code — reconnaissance (read-only), implementation
|
|
@@ -52,8 +64,10 @@ which folder the current repository writes to.
|
|
|
52
64
|
- **A verdict that checks the report against the worktree.** The agent's own account of what
|
|
53
65
|
it changed is compared with what git says actually changed, and a mismatch is a failed run
|
|
54
66
|
rather than a nicer-sounding summary.
|
|
55
|
-
- **
|
|
56
|
-
the run
|
|
67
|
+
- **Four hooks that hold the rules the prompts only ask for**: a reply is refused when it does not
|
|
68
|
+
match the recorded state of the run, a dispatcher call is refused when it carries no order id, a
|
|
69
|
+
file edit is refused inside a repository a live run holds, and deletion of the run store is
|
|
70
|
+
refused when an agent is the one asking.
|
|
57
71
|
|
|
58
72
|
## Requirements
|
|
59
73
|
|
package/bin/codex-bridge.mjs
CHANGED
|
@@ -5,7 +5,10 @@ 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';
|
|
10
13
|
import { uninstall } from '../cli/uninstall.mjs';
|
|
11
14
|
import { update } from '../cli/update.mjs';
|
|
@@ -17,6 +20,10 @@ Usage:
|
|
|
17
20
|
codex-bridge update [--scope user|project] [--host <path>] [--dry-run] [--force]
|
|
18
21
|
codex-bridge uninstall [--scope user|project] [--host <path>] [--dry-run]
|
|
19
22
|
codex-bridge doctor [--scope user|project] [--host <path>]
|
|
23
|
+
codex-bridge projects [<name>] [--json]
|
|
24
|
+
codex-bridge prune <project> [<run>] [--purge] [--older-than <age>] [-f] [--json]
|
|
25
|
+
codex-bridge prune --all-projects [--older-than <age>] [-f] [--json]
|
|
26
|
+
codex-bridge read <run>
|
|
20
27
|
codex-bridge stop <run>
|
|
21
28
|
codex-bridge --help
|
|
22
29
|
codex-bridge --version
|
|
@@ -26,6 +33,9 @@ Commands:
|
|
|
26
33
|
update Update a recorded codex-bridge installation
|
|
27
34
|
uninstall Remove installed files while preserving run artifacts
|
|
28
35
|
doctor Diagnose the selected Claude Code host
|
|
36
|
+
projects List projects or runs from the run store
|
|
37
|
+
prune Remove archived transport, or purge selected run folders
|
|
38
|
+
read Render a run's structured event stream
|
|
29
39
|
stop Stop a running Codex run and record FAIL`;
|
|
30
40
|
|
|
31
41
|
function commandOptions(command, argv) {
|
|
@@ -76,6 +86,25 @@ export async function main(argv, io = console) {
|
|
|
76
86
|
io.log(result.output);
|
|
77
87
|
return result.exitCode;
|
|
78
88
|
}
|
|
89
|
+
if (command === 'read') {
|
|
90
|
+
if (rest.length !== 1) {
|
|
91
|
+
io.error('codex-bridge read requires exactly one run folder (full path or bare name).');
|
|
92
|
+
return 2;
|
|
93
|
+
}
|
|
94
|
+
const result = read({ run: rest[0] });
|
|
95
|
+
io.log(result.output);
|
|
96
|
+
return result.exitCode;
|
|
97
|
+
}
|
|
98
|
+
if (command === 'projects') {
|
|
99
|
+
const result = projects(rest);
|
|
100
|
+
io.log(result.output);
|
|
101
|
+
return result.exitCode;
|
|
102
|
+
}
|
|
103
|
+
if (command === 'prune') {
|
|
104
|
+
const result = await prune(rest);
|
|
105
|
+
io.log(result.output);
|
|
106
|
+
return result.exitCode;
|
|
107
|
+
}
|
|
79
108
|
if (command === 'install' || command === 'update' || command === 'uninstall') {
|
|
80
109
|
const options = commandOptions(command, rest);
|
|
81
110
|
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
|
|
@@ -73,7 +86,12 @@ async function hookChecks(host, record) {
|
|
|
73
86
|
return Promise.all(HOOK_DEFINITIONS.map(async (definition) => {
|
|
74
87
|
const key = `hook:${definition.event}`;
|
|
75
88
|
if (!record) return check(key, 'warn', 'cannot check before installation');
|
|
76
|
-
|
|
89
|
+
// The file, not just the event: PreToolUse carries two hooks of this package, and matching
|
|
90
|
+
// by event alone reported the worktree lock's matcher as pointing at order-gate.mjs. The
|
|
91
|
+
// hook line is the only place an operator can see WHICH file a matcher was registered for,
|
|
92
|
+
// so a wrong name here is worse than no line at all.
|
|
93
|
+
const recorded = record.hooks.find((hook) => hook.event === definition.event
|
|
94
|
+
&& path.basename(hook.path) === definition.file);
|
|
77
95
|
if (!recorded) {
|
|
78
96
|
return check(key, 'warn', `${definition.event} hook is not present in the installation record`);
|
|
79
97
|
}
|
|
@@ -153,6 +171,8 @@ export async function diagnose({ host, codexProbe = probeCodex, currentPackage }
|
|
|
153
171
|
const rules = await rulesCheck(host, record);
|
|
154
172
|
checks.push(rules);
|
|
155
173
|
checks.push(...await hookChecks(host, record));
|
|
174
|
+
const retention = retentionCheck(host);
|
|
175
|
+
checks.push(retention);
|
|
156
176
|
|
|
157
177
|
const codex = codexProbe();
|
|
158
178
|
checks.push(check('codex', codex.available ? 'ok' : 'warn', codex.value || 'available'));
|
|
@@ -164,7 +184,7 @@ export async function diagnose({ host, codexProbe = probeCodex, currentPackage }
|
|
|
164
184
|
|
|
165
185
|
return {
|
|
166
186
|
exitCode: !record || recordBroken || missingFiles.length || rules.status === 'fail'
|
|
167
|
-
|| projectRuns.status === 'fail' ? 1 : 0,
|
|
187
|
+
|| retention.status === 'fail' || projectRuns.status === 'fail' ? 1 : 0,
|
|
168
188
|
checks,
|
|
169
189
|
record,
|
|
170
190
|
missingFiles,
|
|
@@ -172,5 +192,8 @@ export async function diagnose({ host, codexProbe = probeCodex, currentPackage }
|
|
|
172
192
|
}
|
|
173
193
|
|
|
174
194
|
export function renderDoctor(result) {
|
|
175
|
-
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');
|
|
176
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
|
@@ -164,15 +164,17 @@ export function validateInstallRecord(record) {
|
|
|
164
164
|
if (!hook || typeof hook !== 'object' || Array.isArray(hook)) {
|
|
165
165
|
throw new Error('installation record hook must be an object');
|
|
166
166
|
}
|
|
167
|
-
const
|
|
168
|
-
if (!
|
|
167
|
+
const definitions = HOOK_DEFINITIONS.filter((entry) => entry.event === hook.event);
|
|
168
|
+
if (!definitions.length || typeof hook.path !== 'string' || !hook.path) {
|
|
169
169
|
throw new Error('installation record hook must identify a supported event and its path');
|
|
170
170
|
}
|
|
171
171
|
if (path.isAbsolute(hook.path) || hook.path.split(/[\\/]/).includes('..')) {
|
|
172
172
|
throw new Error('installation record hook path must stay relative to the host root');
|
|
173
173
|
}
|
|
174
|
-
|
|
175
|
-
|
|
174
|
+
const definition = definitions.find((entry) => path.basename(hook.path) === entry.file);
|
|
175
|
+
if (!definition || !record.files.includes(hook.path)) {
|
|
176
|
+
const files = definitions.map((entry) => entry.file).join(' or ');
|
|
177
|
+
throw new Error(`installation record hook path must name the installed ${files}`);
|
|
176
178
|
}
|
|
177
179
|
}
|
|
178
180
|
return record;
|
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
|
+
}
|