@lyupro/codex-bridge 0.3.1 → 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.
Files changed (58) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/README.md +88 -84
  3. package/bin/codex-bridge.mjs +38 -11
  4. package/cli/copy.mjs +7 -6
  5. package/cli/doctor.mjs +73 -12
  6. package/cli/hook-targets.mjs +39 -0
  7. package/cli/hook.mjs +34 -0
  8. package/cli/hosts.mjs +28 -4
  9. package/cli/install-record.mjs +287 -0
  10. package/cli/install.mjs +73 -39
  11. package/cli/invoked-directly.mjs +37 -0
  12. package/cli/manifest.mjs +90 -164
  13. package/cli/permissions.mjs +179 -0
  14. package/cli/projects.mjs +20 -5
  15. package/cli/prune-args.mjs +1 -1
  16. package/cli/remove-layout.mjs +59 -0
  17. package/cli/rules-owners.mjs +6 -8
  18. package/cli/runs-inventory.mjs +2 -1
  19. package/cli/settings-merge.mjs +182 -50
  20. package/cli/stop.mjs +33 -20
  21. package/cli/uninstall.mjs +64 -36
  22. package/cli/unlock.mjs +239 -0
  23. package/cli/update.mjs +109 -48
  24. package/package.json +2 -2
  25. package/scripts/check-file-size.mjs +8 -10
  26. package/scripts/isolated-roots.mjs +13 -0
  27. package/scripts/run-tests.mjs +14 -4
  28. package/src/agents/codex-build.md +17 -13
  29. package/src/agents/codex-review.md +15 -9
  30. package/src/agents/codex-scout.md +16 -11
  31. package/src/commands/env.md +4 -4
  32. package/src/commands/usage.md +8 -6
  33. package/src/heartbeat.mjs +1 -1
  34. package/src/hook-definitions.mjs +17 -4
  35. package/src/hooks/guard-tries.mjs +59 -0
  36. package/src/hooks/live-runs.mjs +31 -20
  37. package/src/hooks/order-gate.mjs +7 -2
  38. package/src/hooks/reply-guard.mjs +4 -60
  39. package/src/hooks/stop-guard.mjs +71 -0
  40. package/src/json-file.mjs +30 -0
  41. package/src/meta/chain.mjs +8 -6
  42. package/src/meta/paths.mjs +5 -9
  43. package/src/meta/pre-start.mjs +30 -0
  44. package/src/meta/run-state.mjs +31 -16
  45. package/src/process-identity.mjs +140 -0
  46. package/src/required-inputs.mjs +42 -0
  47. package/src/run-config.mjs +4 -8
  48. package/src/runner/args.mjs +26 -3
  49. package/src/runner/attach.mjs +32 -19
  50. package/src/runner/codex-cmd.mjs +1 -1
  51. package/src/runner/continuation.mjs +34 -3
  52. package/src/runner/launcher.mjs +54 -28
  53. package/src/runner/project-dir.mjs +4 -3
  54. package/src/runner/scope-check.mjs +159 -0
  55. package/src/runner/worker.mjs +13 -5
  56. package/src/stop-contract.mjs +15 -0
  57. package/src/write-meta.mjs +12 -1
  58. package/cli/sweep.mjs +0 -144
package/CHANGELOG.md CHANGED
@@ -4,6 +4,98 @@ 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
+ ## [Unreleased]
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
+
53
+ ## [0.4.0] - 2026-08-09
54
+
55
+ ### Added
56
+
57
+ - Scope patterns are checked before Codex starts. A pattern that cannot match anything — an
58
+ absolute or drive-qualified path, backslash separators, a `..` segment, or a spelling no
59
+ repository file answers to — is refused with no run folder and no quota spent, for all three
60
+ dispatchers. The file list comes from `git ls-files --cached --others --exclude-standard`, so
61
+ ignored output does not count as repository content. A file the run is meant to create is
62
+ declared with `--scope-new`, accepted only by `codex-build` and recorded in `worker.json`.
63
+ - `codex-bridge permissions` reports, adds, and removes the optional shell permission rules for
64
+ both CLI names and the clone entry point. The exact allow and deny strings merge into
65
+ `settings.json`, preserve foreign rules, and are also removed by `uninstall`; `doctor` reports
66
+ an absent or partial set as an optional warning.
67
+
68
+ ### Changed
69
+
70
+ - `codex-bridge sweep` is now `codex-bridge unlock`. The default scope is the current repository;
71
+ a bare project name remains addressable, and `--all` is now required for the whole store. The
72
+ report shows age, silence, and `alive` / `dead` / `foreign` / `unverified` identity verdicts;
73
+ confirmed-alive records are refused with `codex-bridge stop <run>` instead of being closed.
74
+
75
+ ### Fixed
76
+
77
+ - Run records now verify that a pid still belongs to the process that started the run, using the
78
+ process start time and a fresh heartbeat before falling back to an OS probe. A reused or
79
+ unverified pid is never signalled by `stop`; `codex-bridge unlock` remains the non-killing way to
80
+ close a record whose process identity cannot be confirmed.
81
+ - JSON files written by PowerShell with a UTF-8 BOM are now read consistently by the CLI and
82
+ installed hooks, and one command run creates at most one settings backup without deleting older
83
+ backups.
84
+ - A run a guard refused before Codex ever started no longer costs the next attempt a round trip. Such a folder now closes as `aborted_pre_start` instead of the same `failed` a twenty-minute paid run gets, and the chain gate, the continuation limit and the tree baseline all skip it: the same order id starts as a first run, silently, with no `continue:` grant. Reported from a parallel `codex-build` launch — the second run was correctly refused with "two writing runs in one tree are prohibited", and its one-millisecond folder then made the retry demand a grant for work that never happened. Folders written before this release are recognised by what `meta.json` records — `exit`, `session_id`, `events_bytes`, `stderr_bytes`, `tokens_reported` — rather than by whether `events.jsonl` is still on disk, because retention deletes that file from paid runs too. The folder itself is kept: it is the audit trail of who was refused and why.
85
+ - `chainBaseline()` takes the first run of the chain that actually started, so a pre-start folder sorting first no longer leaves the next pass without the `state-before.txt` its work is measured against.
86
+ - `uninstall --dry-run` names each hook by its own matcher. Three of the four hooks are PreToolUse,
87
+ and a lookup by event alone reported `Agent|Task` for the worktree lock and the prune guard too —
88
+ in the one line an operator reads to see what a removal is about to touch. The removal itself was
89
+ never affected: hooks are found by command, not by matcher.
90
+ - The order gate's refusal now shows the line it found. A caller who wrote
91
+ `scope (you may create/modify ONLY these):` was told scope was missing, re-read their own prompt,
92
+ saw it written there, and had nothing to act on; an autonomous run rewrites the same text forever.
93
+ The refusal names the candidate line under the input it belongs to and says why it was not
94
+ accepted — a qualifier between the label and the colon, a value that is still a placeholder, or no
95
+ separator at all — and stays silent when no candidate exists, because then "the input is missing"
96
+ is the truth. The parser accepts exactly what it accepted before: deriving scope from prose would
97
+ give away the only guarantee that a run cannot touch files nobody listed.
98
+
7
99
  ## [0.3.1] - 2026-08-07
8
100
 
9
101
  ### Added
package/README.md CHANGED
@@ -1,106 +1,110 @@
1
1
  # codex-bridge
2
2
 
3
- Delegate implementation, reconnaissance and code review from Claude Code to the
4
- [Codex CLI](https://developers.openai.com/codex/cli), so the work runs on a Codex
5
- subscription instead of the Claude one.
3
+ **Delegate implementation, reconnaissance and review from Claude Code to Codex — five host guards, zero runtime dependencies, zero build steps.**
4
+
5
+ [![npm version](https://img.shields.io/npm/v/@lyupro/codex-bridge)](https://www.npmjs.com/package/@lyupro/codex-bridge)
6
+ [![Node.js](https://img.shields.io/node/v/@lyupro/codex-bridge)](https://www.npmjs.com/package/@lyupro/codex-bridge)
7
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](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
- npx @lyupro/codex-bridge install # one-off, into ~/.claude
11
- npm i -g @lyupro/codex-bridge # install both command names globally
12
- codex-bridge install # full name, from any directory
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 doctor # what is installed, where it points, is codex alive
18
- node bin/codex-bridge.mjs uninstall # removes exactly what was installed
19
- node bin/codex-bridge.mjs stop <run> # close a hanging run by hand, without hunting for a pid
20
- node bin/codex-bridge.mjs read <run> # read a run: its events rendered as text, on demand
21
- node bin/codex-bridge.mjs projects # what the run store holds: projects, runs, weight, tokens
22
- node bin/codex-bridge.mjs prune <project> # reclaim disk space, transport only unless told otherwise
23
- node bin/codex-bridge.mjs sweep [<project>] # close running 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
24
36
  ```
25
37
 
26
- When a global install and a clone coexist, there are two copies of the package. `codex-bridge update`
27
- and `codexb update` copy host files from whichever copy launched the command, so `codexb update`
28
- from `PATH` can silently revert a host that `npm run dev:install` from the clone just updated.
29
-
30
- `install` copies the package into the host's `agents/codex/`, its slash commands into
31
- `commands/codex/`, and registers its four hooks in `settings.json` — **merged**, so hooks that
32
- are already there survive, and the file is backed up before every write. Run it twice and the
33
- second run does nothing. `--dry-run` prints the plan and touches nothing.
34
-
35
- `update` moves an existing installation to the current version. It knows the sha256 of every file
36
- as it was installed, so it can tell a file you edited on the host from one left over by an older
37
- version: outdated files are refreshed silently, files you changed by hand stop the run and are
38
- named, and `--force` is what overwrites them. A file the package no longer ships is removed only
39
- if you never touched it.
40
-
41
- `uninstall` removes only what the install recorded: a file you put in `agents/codex/` yourself
42
- stays, and `codex-runs/` is never touched — those are your run artifacts, not the package.
43
-
44
- `stop` takes a run folder — a full path or a bare name from the current project's runs directory —
45
- kills that run's whole process tree and closes the folder the way an abandoned run is closed: a
46
- `FAIL` verdict naming what the run left in the worktree. A run that already has a verdict is left
47
- alone. Runs end on their own time budget now, so this is for the case where something wedged
48
- anyway.
49
-
50
- Run artifacts live in `codex-runs/<project>/`, one folder per repository, marked with a
51
- `.project.json` holding that repository's path. Two checkouts that share a directory name — two
52
- different `api` — therefore get `api` and `api-2` instead of one mixed history. `doctor` prints
53
- which folder the current repository writes to.
54
-
55
- `projects` reads that store back: without an argument, one line per project — runs, weight, tokens
56
- spent, how many are running right now; with a project name, one line per run. `prune` is the only
57
- command in the package that destroys data, and it is built to be hard to fire by accident: without
58
- `-f` it prints a plan and deletes nothing, `-f` still asks before each step, and with no terminal
59
- it refuses outright, because deletion is an operator action and there is no flag around it.
60
- By default it removes transport only — event streams and stderr, the megabytes — and leaves
61
- accounting, reports and worktree snapshots alone; `--purge` is what takes a folder whole.
62
- The same transport is dropped automatically from runs older than 30 days when a new run starts,
63
- which the reply says out loud; `retention` in `run-config.json` changes the age or turns it off.
64
-
65
- `sweep` is housekeeping for records left by a dead runner. With no project it scans the whole
66
- store; with one project it limits the scan to that folder. It uses the same dead-pid decision as
67
- start-of-run cleanup, never closes a live pid even when its heartbeat is stale, and never deletes
68
- run artifacts. A stale live-pid record is named with the exact `codex-bridge stop <run>` command
69
- needed to close it.
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.
70
39
 
71
- ## What it gives you
40
+ Use `--scope project` to install the Claude Code-facing files under `<repo>/.claude`; the default user scope uses `~/.claude`.
72
41
 
73
- - **Three dispatcher agents** for Claude Code — reconnaissance (read-only), implementation
74
- (write access limited to a declared scope), and an independent review by a second model.
75
- - **A runner that survives a dropped call.** The launcher hands the run to a detached
76
- worker, so a killed dispatcher no longer leaves the work unexplainable: the run folder is
77
- closed with artifacts either way.
78
- - **A verdict that checks the report against the worktree.** The agent's own account of what
79
- it changed is compared with what git says actually changed, and a mismatch is a failed run
80
- rather than a nicer-sounding summary.
81
- - **Four hooks that hold the rules the prompts only ask for**: a reply is refused when it does not
82
- match the recorded state of the run, a dispatcher call is refused when it carries no order id, a
83
- file edit is refused inside a repository a live run holds, and deletion of the run store is
84
- refused when an agent is the one asking.
42
+ Installation has two roots:
85
43
 
86
- ## Requirements
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
87
54
 
88
- - Node.js >= 24 (the file-size gate uses `fs.glob`, the test runner uses directory globs)
89
- - The `codex` CLI, authenticated (`codex --version`, `codex login`)
90
- - Claude Code, for the agents and the hook to be registered in
55
+ ```bash
56
+ codex-bridge doctor
57
+ ```
91
58
 
92
- No runtime dependencies: everything here is plain `.mjs` on the Node standard library, and
93
- 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.
94
60
 
95
- ## Development
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
96
98
 
97
99
  ```bash
98
100
  git clone https://github.com/lyupro/codex-bridge.git
99
101
  cd codex-bridge
100
- git config core.hooksPath .githooks # pre-commit file-size gate, 400 lines per source file
101
- npm test # node --test, no dependencies to install
102
+ git config core.hooksPath .githooks
103
+ npm test
102
104
  ```
103
105
 
106
+ The Git hook enforces the 400-line source-file limit.
107
+
104
108
  ## License
105
109
 
106
- MIT
110
+ [MIT](LICENSE)
@@ -1,16 +1,17 @@
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';
9
9
  import { read } from '../cli/read.mjs';
10
10
  import { packageInfo } from '../cli/manifest.mjs';
11
+ import { permissions } from '../cli/permissions.mjs';
11
12
  import { prune } from '../cli/prune.mjs';
12
13
  import { stop } from '../cli/stop.mjs';
13
- import { sweep } from '../cli/sweep.mjs';
14
+ import { unlock } from '../cli/unlock.mjs';
14
15
  import { uninstall } from '../cli/uninstall.mjs';
15
16
  import { update } from '../cli/update.mjs';
16
17
 
@@ -19,14 +20,16 @@ export const HELP = `codex-bridge — Claude Code dispatchers for Codex
19
20
  Usage:
20
21
  codex-bridge install [--scope user|project] [--host <path>] [--dry-run] [--force]
21
22
  codex-bridge update [--scope user|project] [--host <path>] [--dry-run] [--force]
23
+ codex-bridge permissions [add|remove] [--scope user|project] [--host <path>]
22
24
  codex-bridge uninstall [--scope user|project] [--host <path>] [--dry-run]
23
25
  codex-bridge doctor [--scope user|project] [--host <path>]
24
26
  codex-bridge projects [<name>] [--json]
25
27
  codex-bridge prune <project> [<run>] [--purge] [--older-than <age>] [-f] [--json]
26
28
  codex-bridge prune --all-projects [--older-than <age>] [-f] [--json]
27
- codex-bridge sweep [<project>]
29
+ codex-bridge unlock [<project>|--all]
28
30
  codex-bridge read <run>
29
31
  codex-bridge stop <run>
32
+ codex-bridge hook <name>
30
33
  codexb <same command forms as codex-bridge>
31
34
  codex-bridge --help
32
35
  codex-bridge --version
@@ -34,13 +37,19 @@ Usage:
34
37
  Commands:
35
38
  install Install codex-bridge into the selected Claude Code host
36
39
  update Update a recorded codex-bridge installation
40
+ permissions Show, add, or remove optional shell permission rules
37
41
  uninstall Remove installed files while preserving run artifacts
38
42
  doctor Diagnose the selected Claude Code host
39
43
  projects List projects or runs from the run store
40
44
  prune Remove archived transport, or purge selected run folders
41
- sweep Close running records whose runner is gone
45
+ unlock Close running records whose runner is gone
42
46
  read Render a run's structured event stream
43
- 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';
44
53
 
45
54
  function commandOptions(command, argv) {
46
55
  const options = {};
@@ -90,6 +99,7 @@ export async function main(argv, io = console) {
90
99
  io.log(result.output);
91
100
  return result.exitCode;
92
101
  }
102
+ if (command === HOOK_COMMAND) return hook(rest, io);
93
103
  if (command === 'read') {
94
104
  if (rest.length !== 1) {
95
105
  io.error('codex-bridge read requires exactly one run folder (full path or bare name).');
@@ -110,7 +120,27 @@ export async function main(argv, io = console) {
110
120
  return result.exitCode;
111
121
  }
112
122
  if (command === 'sweep') {
113
- const result = sweep(rest);
123
+ io.error('codex-bridge sweep was renamed to codex-bridge unlock; use the new command.');
124
+ return 2;
125
+ }
126
+ if (command === 'unlock') {
127
+ const result = unlock(rest);
128
+ io.log(result.output);
129
+ return result.exitCode;
130
+ }
131
+ if (command === 'permissions') {
132
+ let action;
133
+ let optionArgs = rest;
134
+ if (rest[0] && !rest[0].startsWith('-')) {
135
+ action = rest[0];
136
+ optionArgs = rest.slice(1);
137
+ }
138
+ if (action && !['add', 'remove'].includes(action)) {
139
+ throw new Error(`unknown permissions action "${action}"`);
140
+ }
141
+ const options = commandOptions(command, optionArgs);
142
+ const host = resolveHost(options);
143
+ const result = await permissions({ host, action });
114
144
  io.log(result.output);
115
145
  return result.exitCode;
116
146
  }
@@ -130,10 +160,7 @@ export async function main(argv, io = console) {
130
160
  return 2;
131
161
  }
132
162
 
133
- const invokedDirectly =
134
- process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url));
135
-
136
- if (invokedDirectly) {
163
+ if (isInvokedDirectly(process.argv[1], import.meta.url)) {
137
164
  main(process.argv.slice(2))
138
165
  .then((exitCode) => { process.exitCode = exitCode; })
139
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, agentsDir) {
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'), agentsDir));
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, agentsDir) {
17
- const expected = await plannedContent(item, agentsDir);
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, agentsDir) {
28
- const content = await plannedContent(item, agentsDir);
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,11 +9,15 @@ import {
9
9
  readInstallRecord,
10
10
  packageInfo,
11
11
  } from './manifest.mjs';
12
+ import { recordTarget } from './install-record.mjs';
13
+ import { inspectPermissions } from './permissions.mjs';
12
14
  import { commandFor, inspectHook } from './settings-merge.mjs';
13
15
  import { readRulesRegistry } from './rules-owners.mjs';
14
16
  import { readRunConfig, retentionNotice } from '../src/run-config.mjs';
15
17
  import { runsRoot } from '../src/runner/runs-root.mjs';
16
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';
17
21
 
18
22
  const WARNING = '\u001b[33m';
19
23
  const RESET = '\u001b[0m';
@@ -41,7 +45,7 @@ function check(key, status, value) {
41
45
 
42
46
  function retentionCheck(host) {
43
47
  try {
44
- const notice = retentionNotice(readRunConfig(path.join(host.agentsDir, 'run-config.json')));
48
+ const notice = retentionNotice(readRunConfig(host.brandConfigPath));
45
49
  return check('retention', notice.enabled ? 'warn' : 'ok', notice.text);
46
50
  } catch (err) {
47
51
  return check('retention', 'fail', `invalid configuration: ${err.message}`);
@@ -49,7 +53,7 @@ function retentionCheck(host) {
49
53
  }
50
54
 
51
55
  async function conventionsCheck(host) {
52
- const file = path.join(host.agentsDir, 'conventions.md');
56
+ const file = host.brandConventionsPath;
53
57
  let content;
54
58
  try {
55
59
  content = await fs.readFile(file, 'utf8');
@@ -101,6 +105,18 @@ function projectRunsCheck() {
101
105
  return check('projectRuns', 'ok', `${path.resolve(resolved.dir)} (${note})`);
102
106
  }
103
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
+
104
120
  export function probeCodex() {
105
121
  const command = process.platform === 'win32' ? process.env.ComSpec || 'cmd.exe' : 'codex';
106
122
  const args = process.platform === 'win32' ? ['/d', '/s', '/c', 'codex --version'] : ['--version'];
@@ -111,7 +127,25 @@ export function probeCodex() {
111
127
  return { available: true, value: (result.stdout || result.stderr).trim() };
112
128
  }
113
129
 
114
- async function hookChecks(host, record) {
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) {
115
149
  return Promise.all(HOOK_DEFINITIONS.map(async (definition) => {
116
150
  const key = `hook:${definition.event}`;
117
151
  if (!record) return check(key, 'warn', 'cannot check before installation');
@@ -124,17 +158,27 @@ async function hookChecks(host, record) {
124
158
  if (!recorded) {
125
159
  return check(key, 'warn', `${definition.event} hook is not present in the installation record`);
126
160
  }
127
- const expected = path.resolve(host.root, recorded.path);
161
+ const expected = recordTarget(host, recorded);
162
+ const fullCommand = commandFor(expected);
163
+ const shortCommand = `codex-bridge hook ${definition.name}`;
128
164
  try {
129
165
  const state = await inspectHook(host.settingsPath, {
130
166
  event: definition.event,
131
167
  matcher: definition.matcher,
132
- command: commandFor(expected),
168
+ command: recorded.command || fullCommand,
169
+ alternateCommands: [fullCommand, shortCommand],
133
170
  });
134
171
  if (state.present) {
135
- return check(key, 'ok', `${definition.event} matcher ${definition.matcher} -> ${expected}`);
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)})`);
136
178
  }
137
- return check(key, 'warn', `${definition.event} matcher ${definition.matcher} does not point to the installed ${definition.file}`);
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)})`);
138
182
  } catch (err) {
139
183
  const value = err.code === 'ENOENT' ? 'settings.json is absent' : `settings.json is invalid: ${err.message}`;
140
184
  return check(key, 'warn', `${definition.event} hook: ${value}`);
@@ -162,7 +206,22 @@ async function rulesCheck(host, record) {
162
206
  : check('rules', 'warn', `${record.rules.path} (modified after installation${ownerNote})`);
163
207
  }
164
208
 
165
- export async function diagnose({ host, codexProbe = probeCodex, currentPackage } = {}) {
209
+ async function permissionsCheck(host) {
210
+ try {
211
+ const status = await inspectPermissions(host.settingsPath);
212
+ // Permission rules are an optional operator action; Plan_22 keeps their absence a warning so
213
+ // doctor does not turn a healthy installation red merely because hardening was not requested.
214
+ // The ask count is part of the line because a full set shadowed by `ask` reads as working and
215
+ // is not: the live run of Plan_22-1 found this line saying `installed (24/24)` over it.
216
+ const shadow = status.askCount ? `, ${status.askCount} shadowed by ask` : '';
217
+ return check('permissions', status.state === 'installed' ? 'ok' : 'warn',
218
+ `${status.state} (${status.present}/${status.total} own strings in allow/deny${shadow})`);
219
+ } catch (err) {
220
+ return check('permissions', 'warn', `cannot inspect permission rules: ${err.message}`);
221
+ }
222
+ }
223
+
224
+ export async function diagnose({ host, codexProbe = probeCodex, bridgeProbe = probeCodexBridge, currentPackage } = {}) {
166
225
  const checks = [sourceCheck()];
167
226
  const hostExists = await exists(host.root);
168
227
  checks.push(check('host', hostExists ? 'ok' : 'warn', `${host.root} (${host.scope}, ${hostExists ? 'exists' : 'absent'})`));
@@ -188,8 +247,8 @@ export async function diagnose({ host, codexProbe = probeCodex, currentPackage }
188
247
 
189
248
  const missingFiles = [];
190
249
  if (record) {
191
- for (const relative of record.files) {
192
- if (!(await isFile(path.join(host.root, relative)))) missingFiles.push(relative);
250
+ for (const file of record.files) {
251
+ if (!(await isFile(recordTarget(host, file)))) missingFiles.push(`${file.root}/${file.path}`);
193
252
  }
194
253
  }
195
254
  checks.push(check(
@@ -199,7 +258,8 @@ export async function diagnose({ host, codexProbe = probeCodex, currentPackage }
199
258
  ));
200
259
  const rules = await rulesCheck(host, record);
201
260
  checks.push(rules);
202
- checks.push(...await hookChecks(host, record));
261
+ checks.push(await permissionsCheck(host));
262
+ checks.push(...await hookChecks(host, record, bridgeProbe));
203
263
  const retention = retentionCheck(host);
204
264
  checks.push(retention);
205
265
  const conventions = await conventionsCheck(host);
@@ -211,6 +271,7 @@ export async function diagnose({ host, codexProbe = probeCodex, currentPackage }
211
271
  checks.push(check('node', nodeMajor >= 24 ? 'ok' : 'fail', `${process.versions.node} (requires >=24)`));
212
272
  checks.push(check('runsRoot', 'ok', path.resolve(runsRoot())));
213
273
  const projectRuns = projectRunsCheck();
274
+ checks.push(liveRunsCheck());
214
275
  checks.push(projectRuns);
215
276
 
216
277
  return {
@@ -225,7 +286,7 @@ export async function diagnose({ host, codexProbe = probeCodex, currentPackage }
225
286
  export function renderDoctor(result) {
226
287
  return result.checks.map(({ key, status, value }) => {
227
288
  const rendered = `[${status}] ${key}: ${value}`;
228
- return ['retention', 'conventions'].includes(key) && status === 'warn'
289
+ return ['retention', 'conventions', 'permissions', 'liveRuns'].includes(key) && status === 'warn'
229
290
  ? `${WARNING}${rendered}${RESET}`
230
291
  : rendered;
231
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
+ }