@lyupro/codex-bridge 0.4.0 → 0.5.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 +88 -97
- package/bin/codex-bridge.mjs +11 -7
- package/cli/copy.mjs +7 -6
- package/cli/doctor.mjs +56 -12
- package/cli/hook-targets.mjs +39 -0
- package/cli/hook.mjs +34 -0
- package/cli/hosts.mjs +28 -4
- package/cli/install-record.mjs +287 -0
- package/cli/install.mjs +67 -39
- package/cli/invoked-directly.mjs +37 -0
- package/cli/manifest.mjs +88 -173
- package/cli/projects.mjs +20 -5
- package/cli/remove-layout.mjs +59 -0
- package/cli/settings-merge.mjs +91 -7
- package/cli/uninstall.mjs +47 -32
- package/cli/update.mjs +109 -48
- package/package.json +2 -2
- package/scripts/isolated-roots.mjs +13 -0
- package/scripts/run-tests.mjs +14 -4
- package/src/agents/codex-build.md +15 -13
- package/src/agents/codex-review.md +13 -9
- package/src/agents/codex-scout.md +14 -11
- package/src/commands/env.md +4 -4
- package/src/commands/usage.md +8 -6
- package/src/hook-definitions.mjs +17 -4
- package/src/hooks/guard-tries.mjs +59 -0
- package/src/hooks/live-runs.mjs +20 -10
- package/src/hooks/reply-guard.mjs +1 -58
- package/src/hooks/stop-guard.mjs +71 -0
- package/src/runner/args.mjs +10 -1
- package/src/runner/attach.mjs +9 -2
- package/src/runner/continuation.mjs +34 -3
- package/src/runner/launcher.mjs +15 -13
- package/src/stop-contract.mjs +15 -0
- package/src/write-meta.mjs +4 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,50 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.5.0] - 2026-08-11
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- A fifth host guard now catches `TaskStop` while a live Codex run is still writing to the
|
|
14
|
+
repository, names the run and points to `codex-bridge stop <run>` before allowing a repeated
|
|
15
|
+
request. `doctor` and `projects` also surface active runs, so stopping a dispatcher wrapper is
|
|
16
|
+
no longer mistaken for stopping its detached worker.
|
|
17
|
+
- `codex-bridge hook <name>` gives Claude Code a stable way to invoke each installed guard, and
|
|
18
|
+
`meta.json` now records the package version that produced a run. The usage report can therefore
|
|
19
|
+
separate historical runs made outside this runner from runs that should satisfy its sandbox
|
|
20
|
+
contract.
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- **Breaking:** the installed package layout now uses `agents/codex-bridge/` instead of
|
|
25
|
+
`agents/codex/`, `commands/codex-bridge/` instead of `commands/codex/`, and
|
|
26
|
+
`~/.lyupro/.codex-bridge/lib/` for the runner and guards. Slash commands are now
|
|
27
|
+
`/codex-bridge:usage` and `/codex-bridge:env`, and hooks in `settings.json` use
|
|
28
|
+
`codex-bridge hook <name>` when the matching package version is available. Run
|
|
29
|
+
`codex-bridge update` to migrate an existing installation and retire the old layout.
|
|
30
|
+
- Runs started without an explicit `--slug` now derive a readable folder name from the required
|
|
31
|
+
order id instead of grouping unrelated work under the dispatcher name. Existing run folders
|
|
32
|
+
remain discoverable by their order id and task fingerprint.
|
|
33
|
+
- The README now leads with the package's guarantees, verification and update guidance, and
|
|
34
|
+
limitations before the command reference, and the repository now publishes a security policy
|
|
35
|
+
and private vulnerability-reporting route.
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
- Guards invoked through a global-install symlink no longer exit successfully without running;
|
|
40
|
+
the direct-invocation check now compares resolved files, so a silent guard cannot allow every
|
|
41
|
+
operation while `doctor` reports it healthy. Hook registration also chooses the short command
|
|
42
|
+
form only when the command on `PATH` is this package's exact version, rather than merely present.
|
|
43
|
+
- Retiring the previous layout no longer removes the shared Claude Code `agents/` and `commands/`
|
|
44
|
+
directories. Seeded operator files are moved only after byte verification, and an interrupted
|
|
45
|
+
migration is retried by the next `update` even when the new files are already current.
|
|
46
|
+
- A continuation grant that loses its `--continue` flag is now refused before attachment instead
|
|
47
|
+
of replaying an older run's verdict as though new work ran. Attachment output also states
|
|
48
|
+
explicitly that no new work was started.
|
|
49
|
+
- The test suite now isolates both installation roots, preventing tests from writing fixtures or
|
|
50
|
+
seeded configuration into the operator's real `~/.lyupro/.codex-bridge/` directory. Migration
|
|
51
|
+
dry runs also name the hook event and matcher instead of printing `undefined`.
|
|
52
|
+
|
|
9
53
|
## [0.4.0] - 2026-08-09
|
|
10
54
|
|
|
11
55
|
### Added
|
package/README.md
CHANGED
|
@@ -1,119 +1,110 @@
|
|
|
1
1
|
# codex-bridge
|
|
2
2
|
|
|
3
|
-
Delegate implementation, reconnaissance and
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
**Delegate implementation, reconnaissance and review from Claude Code to Codex — five host guards, zero runtime dependencies, zero build steps.**
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/@lyupro/codex-bridge)
|
|
6
|
+
[](https://www.npmjs.com/package/@lyupro/codex-bridge)
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
|
|
9
|
+
## What it gives you
|
|
10
|
+
|
|
11
|
+
- **Runs survive the caller.** A detached worker keeps the run and its artifacts alive after the dispatcher exits.
|
|
12
|
+
- **Invalid work is rejected before quota is spent.** Scope, order, retry, branch, and continuation checks run before a run folder is created.
|
|
13
|
+
- **Verdicts come from artifacts.** CLI events, git state, and the declared report decide the result, not model claims about itself.
|
|
14
|
+
- **Identical retries attach safely.** Repeating the same command joins the active run instead of starting another one.
|
|
15
|
+
- **5 host guards enforce the boundary.** `reply-guard`, `order-gate`, `worktree-lock`, `prune-guard`, and `stop-guard` cover replies, orders, edits, deletion, and stopping.
|
|
16
|
+
- **Installation is identifiable and reversible.** Writes to `settings.json` are merged and backed up; uninstall removes recorded package files only.
|
|
17
|
+
- **Every run leaves an audit folder.** The verbatim task, scope, before/after git state, events, report, verdict, and reason remain together.
|
|
18
|
+
- **Zero runtime dependencies and zero build steps.** The package is plain `.mjs` on the Node.js standard library.
|
|
19
|
+
|
|
20
|
+
The current suite contains **603 automated tests: 602 passing and 1 skipped**.
|
|
21
|
+
|
|
22
|
+
## Requirements
|
|
23
|
+
|
|
24
|
+
- Node.js >= 24
|
|
25
|
+
- An authenticated [Codex CLI](https://developers.openai.com/codex/cli): `codex --version` and `codex login`
|
|
26
|
+
- Claude Code, which hosts the agents, commands, and guards
|
|
6
27
|
|
|
7
28
|
## Install
|
|
8
29
|
|
|
30
|
+
For a one-off user installation, global access, or a clone entry point:
|
|
31
|
+
|
|
9
32
|
```bash
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
codexb install # short name, same command
|
|
14
|
-
node bin/codex-bridge.mjs install # from a clone, same thing
|
|
15
|
-
node bin/codex-bridge.mjs install --scope project # into <repo>/.claude
|
|
16
|
-
node bin/codex-bridge.mjs update # bring an installation up to the current version
|
|
17
|
-
node bin/codex-bridge.mjs permissions # show optional shell permission rules
|
|
18
|
-
node bin/codex-bridge.mjs permissions add # add the complete allow/deny rule set
|
|
19
|
-
node bin/codex-bridge.mjs permissions remove # remove only codex-bridge's rule strings
|
|
20
|
-
node bin/codex-bridge.mjs doctor # what is installed, where it points, is codex alive
|
|
21
|
-
node bin/codex-bridge.mjs uninstall # removes exactly what was installed
|
|
22
|
-
node bin/codex-bridge.mjs stop <run> # close a hanging run by hand, without hunting for a pid
|
|
23
|
-
node bin/codex-bridge.mjs read <run> # read a run: its events rendered as text, on demand
|
|
24
|
-
node bin/codex-bridge.mjs projects # what the run store holds: projects, runs, weight, tokens
|
|
25
|
-
node bin/codex-bridge.mjs prune <project> # reclaim disk space, transport only unless told otherwise
|
|
26
|
-
node bin/codex-bridge.mjs unlock [<project>|--all] # close records whose runner is gone
|
|
33
|
+
npm i -g @lyupro/codex-bridge # puts both command names on PATH
|
|
34
|
+
codex-bridge install # into ~/.claude and ~/.lyupro/.codex-bridge/
|
|
35
|
+
codexb install # the short name, same command
|
|
27
36
|
```
|
|
28
37
|
|
|
29
|
-
|
|
30
|
-
and `codexb update` copy host files from whichever copy launched the command, so `codexb update`
|
|
31
|
-
from `PATH` can silently revert a host that `npm run dev:install` from the clone just updated.
|
|
32
|
-
|
|
33
|
-
`install` copies the package into the host's `agents/codex/`, its slash commands into
|
|
34
|
-
`commands/codex/`, and registers its four hooks in `settings.json` — **merged**, so hooks that
|
|
35
|
-
are already there survive, and the file is backed up before every write. Run it twice and the
|
|
36
|
-
second run does nothing. `--dry-run` prints the plan and touches nothing.
|
|
37
|
-
|
|
38
|
-
`update` moves an existing installation to the current version. It knows the sha256 of every file
|
|
39
|
-
as it was installed, so it can tell a file you edited on the host from one left over by an older
|
|
40
|
-
version: outdated files are refreshed silently, files you changed by hand stop the run and are
|
|
41
|
-
named, and `--force` is what overwrites them. A file the package no longer ships is removed only
|
|
42
|
-
if you never touched it.
|
|
43
|
-
|
|
44
|
-
`permissions` manages the optional shell rules in the selected host's `settings.json`. `add`
|
|
45
|
-
merges the exact allow and deny strings for both CLI names, the clone entry point, Bash and
|
|
46
|
-
PowerShell; it preserves foreign rules and is safe to repeat. `remove` takes back those exact
|
|
47
|
-
strings from `allow`, `deny`, or `ask`, so an operator-authored lookalike is left alone. With no
|
|
48
|
-
action it reports whether the rule set is installed, partial, or absent. The command accepts the
|
|
49
|
-
same `--scope` and `--host` selectors as `install`, and `uninstall` removes the same strings too.
|
|
50
|
-
|
|
51
|
-
`uninstall` removes only what the install recorded: a file you put in `agents/codex/` yourself
|
|
52
|
-
stays, and `codex-runs/` is never touched — those are your run artifacts, not the package.
|
|
53
|
-
|
|
54
|
-
`stop` takes a run folder — a full path or a bare name from the current project's runs directory —
|
|
55
|
-
kills that run's whole process tree and closes the folder the way an abandoned run is closed: a
|
|
56
|
-
`FAIL` verdict naming what the run left in the worktree. A run that already has a verdict is left
|
|
57
|
-
alone. Runs end on their own time budget now, so this is for the case where something wedged
|
|
58
|
-
anyway.
|
|
59
|
-
|
|
60
|
-
Run artifacts live in `codex-runs/<project>/`, one folder per repository, marked with a
|
|
61
|
-
`.project.json` holding that repository's path. Two checkouts that share a directory name — two
|
|
62
|
-
different `api` — therefore get `api` and `api-2` instead of one mixed history. `doctor` prints
|
|
63
|
-
which folder the current repository writes to.
|
|
64
|
-
|
|
65
|
-
`projects` reads that store back: without an argument, one line per project — runs, weight, tokens
|
|
66
|
-
spent, how many are running right now; with a project name, one line per run. `prune` is the only
|
|
67
|
-
command in the package that destroys data, and it is built to be hard to fire by accident: without
|
|
68
|
-
`-f` it prints a plan and deletes nothing, `-f` still asks before each step, and with no terminal
|
|
69
|
-
it refuses outright, because deletion is an operator action and there is no flag around it.
|
|
70
|
-
By default it removes transport only — event streams and stderr, the megabytes — and leaves
|
|
71
|
-
accounting, reports and worktree snapshots alone; `--purge` is what takes a folder whole.
|
|
72
|
-
The same transport is dropped automatically from runs older than 30 days when a new run starts,
|
|
73
|
-
which the reply says out loud; `retention` in `run-config.json` changes the age or turns it off.
|
|
74
|
-
|
|
75
|
-
`unlock` is housekeeping for records left by a dead runner. With no argument it acts on the current
|
|
76
|
-
repository; a bare project name limits the scan to that folder, and `--all` explicitly scans the
|
|
77
|
-
whole store. It uses the same process-identity decision as start-of-run cleanup, closes only
|
|
78
|
-
`dead` or `foreign` records, and reports each record's age, silence duration, and identity verdict.
|
|
79
|
-
A confirmed `alive` record is refused rather than closed and is named with the exact
|
|
80
|
-
`codex-bridge stop <run>` command needed to end it; `unverified` records stay untouched with the
|
|
81
|
-
reason visible. The command never deletes run artifacts. The old spelling `sweep` is recognized and
|
|
82
|
-
refused with the rename to `unlock`, rather than treated as an unknown command.
|
|
38
|
+
Two other entry points reach the same command: `npx @lyupro/codex-bridge install` for a one-off run without a global install, and `node bin/codex-bridge.mjs install` from a clone. Every command below works through all three.
|
|
83
39
|
|
|
84
|
-
|
|
40
|
+
Use `--scope project` to install the Claude Code-facing files under `<repo>/.claude`; the default user scope uses `~/.claude`.
|
|
85
41
|
|
|
86
|
-
|
|
87
|
-
(write access limited to a declared scope), and an independent review by a second model.
|
|
88
|
-
- **A runner that survives a dropped call.** The launcher hands the run to a detached
|
|
89
|
-
worker, so a killed dispatcher no longer leaves the work unexplainable: the run folder is
|
|
90
|
-
closed with artifacts either way.
|
|
91
|
-
- **A verdict that checks the report against the worktree.** The agent's own account of what
|
|
92
|
-
it changed is compared with what git says actually changed, and a mismatch is a failed run
|
|
93
|
-
rather than a nicer-sounding summary.
|
|
94
|
-
- **Four hooks that hold the rules the prompts only ask for**: a reply is refused when it does not
|
|
95
|
-
match the recorded state of the run, a dispatcher call is refused when it carries no order id, a
|
|
96
|
-
file edit is refused inside a repository a live run holds, and deletion of the run store is
|
|
97
|
-
refused when an agent is the one asking.
|
|
42
|
+
Installation has two roots:
|
|
98
43
|
|
|
99
|
-
|
|
44
|
+
- `~/.lyupro/.codex-bridge/` contains package runtime files, guards, configuration, conventions, and the installation record.
|
|
45
|
+
- The selected Claude Code root (`~/.claude/` or `<repo>/.claude/`) contains the three agents, two slash commands, merged hook registrations in `settings.json`, and run artifacts under `codex-runs/`.
|
|
46
|
+
|
|
47
|
+
Before every `settings.json` write, the existing file is backed up. Existing settings and foreign hooks are preserved. `--dry-run` prints the plan without writing.
|
|
48
|
+
|
|
49
|
+
If a global install and a clone coexist, you have two package copies. `codex-bridge update`, `codexb update`, and the clone entry point copy files from whichever copy launched the command; using the wrong one can replace a newer host installation with older files.
|
|
50
|
+
|
|
51
|
+
`codex-runs/` contains operator data. Uninstall never removes it.
|
|
52
|
+
|
|
53
|
+
## Verify
|
|
100
54
|
|
|
101
|
-
|
|
102
|
-
-
|
|
103
|
-
|
|
55
|
+
```bash
|
|
56
|
+
codex-bridge doctor
|
|
57
|
+
```
|
|
104
58
|
|
|
105
|
-
|
|
106
|
-
that is deliberate — installing from a clone stays one command, with no build step.
|
|
59
|
+
`doctor` reports the selected host, package source, installed files and guards, Codex availability, retention policy, permissions, live runs, and the current repository's run folder.
|
|
107
60
|
|
|
108
|
-
##
|
|
61
|
+
## Update
|
|
62
|
+
|
|
63
|
+
Update the global package, then refresh the selected host installation:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
npm i -g @lyupro/codex-bridge
|
|
67
|
+
codex-bridge update
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`update` refreshes unchanged package files, refuses to overwrite operator edits unless `--force` is supplied, and removes obsolete files only when their installed fingerprint is unchanged.
|
|
71
|
+
|
|
72
|
+
## Limitations
|
|
73
|
+
|
|
74
|
+
- **Runs are not isolated from one another.** They share one worktree; guards prevent concurrent writers instead of creating sandboxes.
|
|
75
|
+
- **A build run has a 25-minute deadline.** Work and artifacts survive a timeout, but the run may need an explicit continuation.
|
|
76
|
+
- **A dispatcher verdict is not the work outcome.** Judge completion from the worktree and tests as well as the recorded verdict.
|
|
77
|
+
|
|
78
|
+
Transport files from runs at least **30 days** old are pruned automatically when a new run starts. Configuration can change or disable that retention policy; reports, accounting, and worktree snapshots are retained.
|
|
79
|
+
|
|
80
|
+
## Command reference
|
|
81
|
+
|
|
82
|
+
| Command | Purpose |
|
|
83
|
+
| --- | --- |
|
|
84
|
+
| `install [--scope user\|project] [--host <path>] [--dry-run] [--force]` | Install into a Claude Code host. |
|
|
85
|
+
| `update [--scope user\|project] [--host <path>] [--dry-run] [--force]` | Refresh a recorded installation. |
|
|
86
|
+
| `permissions [add\|remove] [--scope user\|project] [--host <path>]` | Inspect or manage optional shell rules. |
|
|
87
|
+
| `uninstall [--scope user\|project] [--host <path>] [--dry-run]` | Remove recorded package files while preserving run artifacts. |
|
|
88
|
+
| `doctor [--scope user\|project] [--host <path>]` | Diagnose the selected host and Codex connection. |
|
|
89
|
+
| `projects [<name>] [--json]` | List projects or runs in the run store. |
|
|
90
|
+
| `prune <project> [<run>] [--purge] [--older-than <age>] [-f] [--json]` | Plan or perform operator-confirmed cleanup. |
|
|
91
|
+
| `unlock [<project>\|--all]` | Close records whose runner is gone. |
|
|
92
|
+
| `read <run>` | Render a run's structured event stream. |
|
|
93
|
+
| `stop <run>` | Stop a run and record a `FAIL` verdict. |
|
|
94
|
+
|
|
95
|
+
Both `codex-bridge` and `codexb` invoke the same command. Run `codex-bridge --help` for exact forms.
|
|
96
|
+
|
|
97
|
+
### Development
|
|
109
98
|
|
|
110
99
|
```bash
|
|
111
100
|
git clone https://github.com/lyupro/codex-bridge.git
|
|
112
101
|
cd codex-bridge
|
|
113
|
-
git config core.hooksPath .githooks
|
|
114
|
-
npm test
|
|
102
|
+
git config core.hooksPath .githooks
|
|
103
|
+
npm test
|
|
115
104
|
```
|
|
116
105
|
|
|
106
|
+
The Git hook enforces the 400-line source-file limit.
|
|
107
|
+
|
|
117
108
|
## License
|
|
118
109
|
|
|
119
|
-
MIT
|
|
110
|
+
[MIT](LICENSE)
|
package/bin/codex-bridge.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/** Dispatches codex-bridge CLI arguments to focused command modules. */
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
import { fileURLToPath } from 'node:url';
|
|
5
3
|
import { diagnose, renderDoctor } from '../cli/doctor.mjs';
|
|
4
|
+
import { isInvokedDirectly } from '../cli/invoked-directly.mjs';
|
|
5
|
+
import { hook } from '../cli/hook.mjs';
|
|
6
6
|
import { resolveHost } from '../cli/hosts.mjs';
|
|
7
7
|
import { install } from '../cli/install.mjs';
|
|
8
8
|
import { projects } from '../cli/projects.mjs';
|
|
@@ -29,6 +29,7 @@ Usage:
|
|
|
29
29
|
codex-bridge unlock [<project>|--all]
|
|
30
30
|
codex-bridge read <run>
|
|
31
31
|
codex-bridge stop <run>
|
|
32
|
+
codex-bridge hook <name>
|
|
32
33
|
codexb <same command forms as codex-bridge>
|
|
33
34
|
codex-bridge --help
|
|
34
35
|
codex-bridge --version
|
|
@@ -43,7 +44,12 @@ Commands:
|
|
|
43
44
|
prune Remove archived transport, or purge selected run folders
|
|
44
45
|
unlock Close running records whose runner is gone
|
|
45
46
|
read Render a run's structured event stream
|
|
46
|
-
stop Stop a running Codex run and record FAIL
|
|
47
|
+
stop Stop a running Codex run and record FAIL
|
|
48
|
+
|
|
49
|
+
Hook dispatch:
|
|
50
|
+
codex-bridge hook <name> Dispatch a registered guard with stdin unchanged`;
|
|
51
|
+
|
|
52
|
+
const HOOK_COMMAND = 'hook';
|
|
47
53
|
|
|
48
54
|
function commandOptions(command, argv) {
|
|
49
55
|
const options = {};
|
|
@@ -93,6 +99,7 @@ export async function main(argv, io = console) {
|
|
|
93
99
|
io.log(result.output);
|
|
94
100
|
return result.exitCode;
|
|
95
101
|
}
|
|
102
|
+
if (command === HOOK_COMMAND) return hook(rest, io);
|
|
96
103
|
if (command === 'read') {
|
|
97
104
|
if (rest.length !== 1) {
|
|
98
105
|
io.error('codex-bridge read requires exactly one run folder (full path or bare name).');
|
|
@@ -153,10 +160,7 @@ export async function main(argv, io = console) {
|
|
|
153
160
|
return 2;
|
|
154
161
|
}
|
|
155
162
|
|
|
156
|
-
|
|
157
|
-
process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url));
|
|
158
|
-
|
|
159
|
-
if (invokedDirectly) {
|
|
163
|
+
if (isInvokedDirectly(process.argv[1], import.meta.url)) {
|
|
160
164
|
main(process.argv.slice(2))
|
|
161
165
|
.then((exitCode) => { process.exitCode = exitCode; })
|
|
162
166
|
.catch((err) => {
|
package/cli/copy.mjs
CHANGED
|
@@ -4,17 +4,18 @@ import path from 'node:path';
|
|
|
4
4
|
import { randomUUID } from 'node:crypto';
|
|
5
5
|
import { replacePlaceholders } from './manifest.mjs';
|
|
6
6
|
|
|
7
|
-
export async function plannedContent(item,
|
|
7
|
+
export async function plannedContent(item, installationRoot) {
|
|
8
|
+
const replacementRoot = item.installationRoot ?? installationRoot;
|
|
8
9
|
const source = await fs.readFile(item.source);
|
|
9
10
|
if (item.processing === 'copy') return source;
|
|
10
11
|
if (item.processing === 'placeholders') {
|
|
11
|
-
return Buffer.from(replacePlaceholders(source.toString('utf8'),
|
|
12
|
+
return Buffer.from(replacePlaceholders(source.toString('utf8'), replacementRoot));
|
|
12
13
|
}
|
|
13
14
|
throw new Error(`unknown install processing "${item.processing}"`);
|
|
14
15
|
}
|
|
15
16
|
|
|
16
|
-
export async function targetMatches(item,
|
|
17
|
-
const expected = await plannedContent(item,
|
|
17
|
+
export async function targetMatches(item, installationRoot) {
|
|
18
|
+
const expected = await plannedContent(item, installationRoot);
|
|
18
19
|
try {
|
|
19
20
|
const actual = await fs.readFile(item.target);
|
|
20
21
|
return actual.equals(expected);
|
|
@@ -24,8 +25,8 @@ export async function targetMatches(item, agentsDir) {
|
|
|
24
25
|
}
|
|
25
26
|
}
|
|
26
27
|
|
|
27
|
-
export async function copyPlannedFile(item,
|
|
28
|
-
const content = await plannedContent(item,
|
|
28
|
+
export async function copyPlannedFile(item, installationRoot) {
|
|
29
|
+
const content = await plannedContent(item, installationRoot);
|
|
29
30
|
await fs.mkdir(path.dirname(item.target), { recursive: true });
|
|
30
31
|
const temporary = path.join(path.dirname(item.target), `.${path.basename(item.target)}.${randomUUID()}.tmp`);
|
|
31
32
|
try {
|
package/cli/doctor.mjs
CHANGED
|
@@ -9,12 +9,15 @@ import {
|
|
|
9
9
|
readInstallRecord,
|
|
10
10
|
packageInfo,
|
|
11
11
|
} from './manifest.mjs';
|
|
12
|
+
import { recordTarget } from './install-record.mjs';
|
|
12
13
|
import { inspectPermissions } from './permissions.mjs';
|
|
13
14
|
import { commandFor, inspectHook } from './settings-merge.mjs';
|
|
14
15
|
import { readRulesRegistry } from './rules-owners.mjs';
|
|
15
16
|
import { readRunConfig, retentionNotice } from '../src/run-config.mjs';
|
|
16
17
|
import { runsRoot } from '../src/runner/runs-root.mjs';
|
|
17
18
|
import { resolveProjectRunsDir } from '../src/runner/project-dir.mjs';
|
|
19
|
+
import { allLiveRuns } from '../src/hooks/live-runs.mjs';
|
|
20
|
+
import { STOP_COMMAND_TEMPLATE } from '../src/stop-contract.mjs';
|
|
18
21
|
|
|
19
22
|
const WARNING = '\u001b[33m';
|
|
20
23
|
const RESET = '\u001b[0m';
|
|
@@ -42,7 +45,7 @@ function check(key, status, value) {
|
|
|
42
45
|
|
|
43
46
|
function retentionCheck(host) {
|
|
44
47
|
try {
|
|
45
|
-
const notice = retentionNotice(readRunConfig(
|
|
48
|
+
const notice = retentionNotice(readRunConfig(host.brandConfigPath));
|
|
46
49
|
return check('retention', notice.enabled ? 'warn' : 'ok', notice.text);
|
|
47
50
|
} catch (err) {
|
|
48
51
|
return check('retention', 'fail', `invalid configuration: ${err.message}`);
|
|
@@ -50,7 +53,7 @@ function retentionCheck(host) {
|
|
|
50
53
|
}
|
|
51
54
|
|
|
52
55
|
async function conventionsCheck(host) {
|
|
53
|
-
const file =
|
|
56
|
+
const file = host.brandConventionsPath;
|
|
54
57
|
let content;
|
|
55
58
|
try {
|
|
56
59
|
content = await fs.readFile(file, 'utf8');
|
|
@@ -102,6 +105,18 @@ function projectRunsCheck() {
|
|
|
102
105
|
return check('projectRuns', 'ok', `${path.resolve(resolved.dir)} (${note})`);
|
|
103
106
|
}
|
|
104
107
|
|
|
108
|
+
function liveRunsCheck() {
|
|
109
|
+
let count;
|
|
110
|
+
try {
|
|
111
|
+
count = allLiveRuns(runsRoot(), { requireConfirmedIdentity: true }).length;
|
|
112
|
+
} catch (err) {
|
|
113
|
+
return check('liveRuns', 'warn', `working-run count unavailable: ${err.message}`);
|
|
114
|
+
}
|
|
115
|
+
if (!count) return check('liveRuns', 'ok', '0 runs working right now');
|
|
116
|
+
const noun = count === 1 ? 'run' : 'runs';
|
|
117
|
+
return check('liveRuns', 'warn', `${count} ${noun} working right now; stop with ${STOP_COMMAND_TEMPLATE}`);
|
|
118
|
+
}
|
|
119
|
+
|
|
105
120
|
export function probeCodex() {
|
|
106
121
|
const command = process.platform === 'win32' ? process.env.ComSpec || 'cmd.exe' : 'codex';
|
|
107
122
|
const args = process.platform === 'win32' ? ['/d', '/s', '/c', 'codex --version'] : ['--version'];
|
|
@@ -112,7 +127,25 @@ export function probeCodex() {
|
|
|
112
127
|
return { available: true, value: (result.stdout || result.stderr).trim() };
|
|
113
128
|
}
|
|
114
129
|
|
|
115
|
-
|
|
130
|
+
export function probeCodexBridge() {
|
|
131
|
+
const command = process.platform === 'win32' ? process.env.ComSpec || 'cmd.exe' : 'codex-bridge';
|
|
132
|
+
const args = process.platform === 'win32' ? ['/d', '/s', '/c', 'codex-bridge --version'] : ['--version'];
|
|
133
|
+
const result = spawnSync(command, args, { encoding: 'utf8', windowsHide: true });
|
|
134
|
+
if (result.error || result.status !== 0) {
|
|
135
|
+
return { available: false, value: (result.stderr || result.error?.message || 'not found').trim() };
|
|
136
|
+
}
|
|
137
|
+
return { available: true, value: (result.stdout || result.stderr).trim() };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function hookVersion(command, record, bridgeProbe) {
|
|
141
|
+
if (command.startsWith('codex-bridge hook ')) {
|
|
142
|
+
const result = bridgeProbe();
|
|
143
|
+
return result.available ? `global command ${result.value}` : `global command unavailable (${result.value})`;
|
|
144
|
+
}
|
|
145
|
+
return `installed copy ${record.name}@${record.version}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function hookChecks(host, record, bridgeProbe) {
|
|
116
149
|
return Promise.all(HOOK_DEFINITIONS.map(async (definition) => {
|
|
117
150
|
const key = `hook:${definition.event}`;
|
|
118
151
|
if (!record) return check(key, 'warn', 'cannot check before installation');
|
|
@@ -125,17 +158,27 @@ async function hookChecks(host, record) {
|
|
|
125
158
|
if (!recorded) {
|
|
126
159
|
return check(key, 'warn', `${definition.event} hook is not present in the installation record`);
|
|
127
160
|
}
|
|
128
|
-
const expected =
|
|
161
|
+
const expected = recordTarget(host, recorded);
|
|
162
|
+
const fullCommand = commandFor(expected);
|
|
163
|
+
const shortCommand = `codex-bridge hook ${definition.name}`;
|
|
129
164
|
try {
|
|
130
165
|
const state = await inspectHook(host.settingsPath, {
|
|
131
166
|
event: definition.event,
|
|
132
167
|
matcher: definition.matcher,
|
|
133
|
-
command:
|
|
168
|
+
command: recorded.command || fullCommand,
|
|
169
|
+
alternateCommands: [fullCommand, shortCommand],
|
|
134
170
|
});
|
|
135
171
|
if (state.present) {
|
|
136
|
-
|
|
172
|
+
const command = state.matchedCommand || recorded.command || fullCommand;
|
|
173
|
+
const form = command.startsWith('codex-bridge hook ') ? 'short' : 'path';
|
|
174
|
+
const reason = form === 'short'
|
|
175
|
+
? 'PATH command uses the globally installed package'
|
|
176
|
+
: 'full path uses the copy placed by the last install';
|
|
177
|
+
return check(key, 'ok', `${definition.event} matcher ${definition.matcher} -> ${expected} (${form} command; ${reason}; ${hookVersion(command, record, bridgeProbe)})`);
|
|
137
178
|
}
|
|
138
|
-
|
|
179
|
+
const command = recorded.command || fullCommand;
|
|
180
|
+
const form = command.startsWith('codex-bridge hook ') ? 'short' : 'path';
|
|
181
|
+
return check(key, 'warn', `${definition.event} matcher ${definition.matcher} does not point to the installed ${definition.file} (${form} command; ${hookVersion(command, record, bridgeProbe)})`);
|
|
139
182
|
} catch (err) {
|
|
140
183
|
const value = err.code === 'ENOENT' ? 'settings.json is absent' : `settings.json is invalid: ${err.message}`;
|
|
141
184
|
return check(key, 'warn', `${definition.event} hook: ${value}`);
|
|
@@ -178,7 +221,7 @@ async function permissionsCheck(host) {
|
|
|
178
221
|
}
|
|
179
222
|
}
|
|
180
223
|
|
|
181
|
-
export async function diagnose({ host, codexProbe = probeCodex, currentPackage } = {}) {
|
|
224
|
+
export async function diagnose({ host, codexProbe = probeCodex, bridgeProbe = probeCodexBridge, currentPackage } = {}) {
|
|
182
225
|
const checks = [sourceCheck()];
|
|
183
226
|
const hostExists = await exists(host.root);
|
|
184
227
|
checks.push(check('host', hostExists ? 'ok' : 'warn', `${host.root} (${host.scope}, ${hostExists ? 'exists' : 'absent'})`));
|
|
@@ -204,8 +247,8 @@ export async function diagnose({ host, codexProbe = probeCodex, currentPackage }
|
|
|
204
247
|
|
|
205
248
|
const missingFiles = [];
|
|
206
249
|
if (record) {
|
|
207
|
-
for (const
|
|
208
|
-
if (!(await isFile(
|
|
250
|
+
for (const file of record.files) {
|
|
251
|
+
if (!(await isFile(recordTarget(host, file)))) missingFiles.push(`${file.root}/${file.path}`);
|
|
209
252
|
}
|
|
210
253
|
}
|
|
211
254
|
checks.push(check(
|
|
@@ -216,7 +259,7 @@ export async function diagnose({ host, codexProbe = probeCodex, currentPackage }
|
|
|
216
259
|
const rules = await rulesCheck(host, record);
|
|
217
260
|
checks.push(rules);
|
|
218
261
|
checks.push(await permissionsCheck(host));
|
|
219
|
-
checks.push(...await hookChecks(host, record));
|
|
262
|
+
checks.push(...await hookChecks(host, record, bridgeProbe));
|
|
220
263
|
const retention = retentionCheck(host);
|
|
221
264
|
checks.push(retention);
|
|
222
265
|
const conventions = await conventionsCheck(host);
|
|
@@ -228,6 +271,7 @@ export async function diagnose({ host, codexProbe = probeCodex, currentPackage }
|
|
|
228
271
|
checks.push(check('node', nodeMajor >= 24 ? 'ok' : 'fail', `${process.versions.node} (requires >=24)`));
|
|
229
272
|
checks.push(check('runsRoot', 'ok', path.resolve(runsRoot())));
|
|
230
273
|
const projectRuns = projectRunsCheck();
|
|
274
|
+
checks.push(liveRunsCheck());
|
|
231
275
|
checks.push(projectRuns);
|
|
232
276
|
|
|
233
277
|
return {
|
|
@@ -242,7 +286,7 @@ export async function diagnose({ host, codexProbe = probeCodex, currentPackage }
|
|
|
242
286
|
export function renderDoctor(result) {
|
|
243
287
|
return result.checks.map(({ key, status, value }) => {
|
|
244
288
|
const rendered = `[${status}] ${key}: ${value}`;
|
|
245
|
-
return ['retention', 'conventions', 'permissions'].includes(key) && status === 'warn'
|
|
289
|
+
return ['retention', 'conventions', 'permissions', 'liveRuns'].includes(key) && status === 'warn'
|
|
246
290
|
? `${WARNING}${rendered}${RESET}`
|
|
247
291
|
: rendered;
|
|
248
292
|
}).join('\n');
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** Describes where each guard is installed and how its registration will be spelled. */
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { HOOK_DEFINITIONS, installedHookPath } from './manifest.mjs';
|
|
4
|
+
import { commandFor, hookRegistration } from './settings-merge.mjs';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* install.mjs and update.mjs each carried their own copy of this, and the copies had already
|
|
8
|
+
* drifted: one returned `root: 'brand'` on every entry and the other did not. Two copies of the
|
|
9
|
+
* list that decides what goes into the operator's settings.json is the same shape of defect
|
|
10
|
+
* Plan_19 had to reconcile between the installer and the hooks.
|
|
11
|
+
*
|
|
12
|
+
* `packageVersion` is what keeps the registration honest: the short command form is only chosen
|
|
13
|
+
* when the codex-bridge on PATH reports this exact version, because on 2026-08-11 an install from
|
|
14
|
+
* the clone wrote `codex-bridge hook <name>` against a global 0.4.0 that has no such subcommand
|
|
15
|
+
* and took every guard on the machine down with it.
|
|
16
|
+
*/
|
|
17
|
+
export function hookTargets(host, env = process.env, packageVersion = null) {
|
|
18
|
+
return HOOK_DEFINITIONS.map((definition) => {
|
|
19
|
+
const target = installedHookPath(host, definition);
|
|
20
|
+
const registration = hookRegistration(definition.name, target, env, packageVersion);
|
|
21
|
+
const fallback = commandFor(target);
|
|
22
|
+
const alternate = registration.command === fallback
|
|
23
|
+
? `codex-bridge hook ${definition.name}`
|
|
24
|
+
: fallback;
|
|
25
|
+
return {
|
|
26
|
+
definition,
|
|
27
|
+
target,
|
|
28
|
+
root: 'brand',
|
|
29
|
+
relative: path.relative(host.brandRoot, target).split(path.sep).join('/'),
|
|
30
|
+
registration,
|
|
31
|
+
spec: {
|
|
32
|
+
event: definition.event,
|
|
33
|
+
matcher: definition.matcher,
|
|
34
|
+
command: registration.command,
|
|
35
|
+
alternateCommands: [alternate],
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
});
|
|
39
|
+
}
|
package/cli/hook.mjs
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** Dispatches a named host hook without changing the guard's stdin or process lifecycle. */
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
4
|
+
import { HOOK_DEFINITIONS } from '../src/hook-definitions.mjs';
|
|
5
|
+
|
|
6
|
+
const HOOKS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'src', 'hooks');
|
|
7
|
+
const VALID_NAMES = HOOK_DEFINITIONS.map(({ name }) => name);
|
|
8
|
+
|
|
9
|
+
function validNames() {
|
|
10
|
+
return VALID_NAMES.join(', ');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function usageError(io, message) {
|
|
14
|
+
io.error(`codex-bridge hook ${message} Valid hook names: ${validNames()}.`);
|
|
15
|
+
return 2;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function hook(args = [], io = console) {
|
|
19
|
+
if (!Array.isArray(args) || args.length !== 1) {
|
|
20
|
+
const detail = Array.isArray(args) && args.length
|
|
21
|
+
? `accepts exactly one hook name; received ${args.length}.`
|
|
22
|
+
: 'requires exactly one hook name.';
|
|
23
|
+
return usageError(io, detail);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const definition = HOOK_DEFINITIONS.find(({ name }) => name === args[0]);
|
|
27
|
+
if (!definition) return usageError(io, `unknown hook name "${args[0]}".`);
|
|
28
|
+
|
|
29
|
+
// Plan_25 replaces absolute paths in foreign settings.json with this command. Importing the
|
|
30
|
+
// proven top-level script keeps its stdin reader and process.exit() contract intact; exporting
|
|
31
|
+
// guard functions would be a second implementation of the live hook boundary.
|
|
32
|
+
await import(pathToFileURL(path.join(HOOKS_DIR, definition.file)).href);
|
|
33
|
+
return typeof process.exitCode === 'number' ? process.exitCode : 0;
|
|
34
|
+
}
|
package/cli/hosts.mjs
CHANGED
|
@@ -13,24 +13,48 @@ function repositoryRoot(start) {
|
|
|
13
13
|
}
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
function claudePaths(root) {
|
|
17
|
+
return {
|
|
18
|
+
agentsDir: path.join(root, 'agents', 'codex-bridge'),
|
|
19
|
+
commandsDir: path.join(root, 'commands', 'codex-bridge'),
|
|
20
|
+
legacyAgentsDir: path.join(root, 'agents', 'codex'),
|
|
21
|
+
legacyCommandsDir: path.join(root, 'commands', 'codex'),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
16
25
|
export function resolveHost({
|
|
17
26
|
scope = 'user',
|
|
18
27
|
host,
|
|
19
28
|
cwd = process.cwd(),
|
|
20
29
|
homedir = os.homedir(),
|
|
21
30
|
codexHome,
|
|
31
|
+
brandRoot,
|
|
22
32
|
} = {}) {
|
|
23
33
|
const resolvedCodexHome = codexHome || process.env.CODEX_HOME || path.join(homedir, '.codex');
|
|
24
34
|
const codexRulesDir = path.join(resolvedCodexHome, 'rules');
|
|
35
|
+
// Plan_25 moves package-owned runtime files out of foreign Claude settings, whose absolute
|
|
36
|
+
// hook paths caused the package-file-layout incident. Keep the brand root overrideable so tests
|
|
37
|
+
// and later installation steps never need to know the operator's home directory.
|
|
38
|
+
const resolvedBrandRoot = path.resolve(
|
|
39
|
+
brandRoot || process.env.CODEX_BRIDGE_HOME || path.join(homedir, '.lyupro', '.codex-bridge'),
|
|
40
|
+
);
|
|
41
|
+
const brandPaths = {
|
|
42
|
+
brandRoot: resolvedBrandRoot,
|
|
43
|
+
brandHooksDir: path.join(resolvedBrandRoot, 'hooks'),
|
|
44
|
+
brandRunnerDir: path.join(resolvedBrandRoot, 'lib'),
|
|
45
|
+
brandConfigPath: path.join(resolvedBrandRoot, 'config.json'),
|
|
46
|
+
brandConventionsPath: path.join(resolvedBrandRoot, 'conventions.md'),
|
|
47
|
+
brandInstallRecordPath: path.join(resolvedBrandRoot, '.installed.json'),
|
|
48
|
+
};
|
|
25
49
|
if (host) {
|
|
26
50
|
const root = path.resolve(host);
|
|
27
51
|
return {
|
|
28
52
|
root,
|
|
29
|
-
|
|
30
|
-
commandsDir: path.join(root, 'commands', 'codex'),
|
|
53
|
+
...claudePaths(root),
|
|
31
54
|
codexRulesDir,
|
|
32
55
|
settingsPath: path.join(root, 'settings.json'),
|
|
33
56
|
scope: 'host',
|
|
57
|
+
...brandPaths,
|
|
34
58
|
};
|
|
35
59
|
}
|
|
36
60
|
if (scope !== 'user' && scope !== 'project') {
|
|
@@ -41,10 +65,10 @@ export function resolveHost({
|
|
|
41
65
|
);
|
|
42
66
|
return {
|
|
43
67
|
root,
|
|
44
|
-
|
|
45
|
-
commandsDir: path.join(root, 'commands', 'codex'),
|
|
68
|
+
...claudePaths(root),
|
|
46
69
|
codexRulesDir,
|
|
47
70
|
settingsPath: path.join(root, 'settings.json'),
|
|
48
71
|
scope,
|
|
72
|
+
...brandPaths,
|
|
49
73
|
};
|
|
50
74
|
}
|