@lyupro/codex-bridge 0.4.0 → 0.5.1

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 CHANGED
@@ -6,6 +6,69 @@ 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.1] - 2026-08-12
10
+
11
+ ### Fixed
12
+
13
+ - Installing or uninstalling no longer fails over a rules-registry lock that is busy for one more
14
+ millisecond. Windows answers a taken lock with three codes, and only one of them is `EEXIST`: a
15
+ lock file whose delete is still pending comes back `EPERM`, and a file another process still
16
+ holds comes back `EBUSY` — the acquisition loop treated both as fatal instead of waiting. All
17
+ three now mean "not yours yet", and the decision lives in one exported check, `isLockTaken()`.
18
+
19
+ ### Changed
20
+
21
+ - The README's first screen names the problem the package solves, keeps the install command inside
22
+ the first 30 lines, adds a second badge row, and shows `doctor` output from a healthy host in
23
+ place of a screenshot.
24
+ - The reference documentation under `docs/` is English-only now (`overview.ru.md` became
25
+ `overview.md`), and the working folders — plans, checklists and audits — left the repository and
26
+ its history: a fresh clone contains the five reference documents and nothing else.
27
+
28
+ ## [0.5.0] - 2026-08-11
29
+
30
+ ### Added
31
+
32
+ - A fifth host guard now catches `TaskStop` while a live Codex run is still writing to the
33
+ repository, names the run and points to `codex-bridge stop <run>` before allowing a repeated
34
+ request. `doctor` and `projects` also surface active runs, so stopping a dispatcher wrapper is
35
+ no longer mistaken for stopping its detached worker.
36
+ - `codex-bridge hook <name>` gives Claude Code a stable way to invoke each installed guard, and
37
+ `meta.json` now records the package version that produced a run. The usage report can therefore
38
+ separate historical runs made outside this runner from runs that should satisfy its sandbox
39
+ contract.
40
+
41
+ ### Changed
42
+
43
+ - **Breaking:** the installed package layout now uses `agents/codex-bridge/` instead of
44
+ `agents/codex/`, `commands/codex-bridge/` instead of `commands/codex/`, and
45
+ `~/.lyupro/.codex-bridge/lib/` for the runner and guards. Slash commands are now
46
+ `/codex-bridge:usage` and `/codex-bridge:env`, and hooks in `settings.json` use
47
+ `codex-bridge hook <name>` when the matching package version is available. Run
48
+ `codex-bridge update` to migrate an existing installation and retire the old layout.
49
+ - Runs started without an explicit `--slug` now derive a readable folder name from the required
50
+ order id instead of grouping unrelated work under the dispatcher name. Existing run folders
51
+ remain discoverable by their order id and task fingerprint.
52
+ - The README now leads with the package's guarantees, verification and update guidance, and
53
+ limitations before the command reference, and the repository now publishes a security policy
54
+ and private vulnerability-reporting route.
55
+
56
+ ### Fixed
57
+
58
+ - Guards invoked through a global-install symlink no longer exit successfully without running;
59
+ the direct-invocation check now compares resolved files, so a silent guard cannot allow every
60
+ operation while `doctor` reports it healthy. Hook registration also chooses the short command
61
+ form only when the command on `PATH` is this package's exact version, rather than merely present.
62
+ - Retiring the previous layout no longer removes the shared Claude Code `agents/` and `commands/`
63
+ directories. Seeded operator files are moved only after byte verification, and an interrupted
64
+ migration is retried by the next `update` even when the new files are already current.
65
+ - A continuation grant that loses its `--continue` flag is now refused before attachment instead
66
+ of replaying an older run's verdict as though new work ran. Attachment output also states
67
+ explicitly that no new work was started.
68
+ - The test suite now isolates both installation roots, preventing tests from writing fixtures or
69
+ seeded configuration into the operator's real `~/.lyupro/.codex-bridge/` directory. Migration
70
+ dry runs also name the hook event and matcher instead of printing `undefined`.
71
+
9
72
  ## [0.4.0] - 2026-08-09
10
73
 
11
74
  ### Added
package/README.md CHANGED
@@ -1,119 +1,135 @@
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
+ [![npm downloads](https://img.shields.io/npm/dm/@lyupro/codex-bridge)](https://www.npmjs.com/package/@lyupro/codex-bridge)
10
+ [![npm unpacked size](https://img.shields.io/npm/unpacked-size/@lyupro/codex-bridge)](https://www.npmjs.com/package/@lyupro/codex-bridge)
11
+ [![dependencies: zero](https://img.shields.io/badge/dependencies-zero-brightgreen)](package.json)
12
+
13
+ Agentic coding spends one model subscription on everything, and implementation is the expensive part. codex-bridge moves that work to a separate [Codex CLI](https://developers.openai.com/codex/cli) subscription: Claude Code plans, dispatches and accepts, Codex executes — and every delegated run is guarded, verified and audited on disk.
6
14
 
7
15
  ## Install
8
16
 
9
17
  ```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 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
18
+ npm i -g @lyupro/codex-bridge # puts both command names on PATH
19
+ codex-bridge install # into ~/.claude and ~/.lyupro/.codex-bridge/
20
+ codexb install # the short name, same command
27
21
  ```
28
22
 
29
- When a global install and a clone coexist, there are two copies of the package. `codex-bridge update`
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.
23
+ 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
24
 
84
- ## What it gives you
25
+ Use `--scope project` to install the Claude Code-facing files under `<repo>/.claude`; the default user scope uses `~/.claude`.
26
+
27
+ Installation has two roots:
28
+
29
+ - `~/.lyupro/.codex-bridge/` contains package runtime files, guards, configuration, conventions, and the installation record.
30
+ - 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/`.
31
+
32
+ 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.
33
+
34
+ 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.
85
35
 
86
- - **Three dispatcher agents** for Claude Code — reconnaissance (read-only), implementation
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.
36
+ `codex-runs/` contains operator data. Uninstall never removes it.
98
37
 
99
38
  ## Requirements
100
39
 
101
- - Node.js >= 24 (the file-size gate uses `fs.glob`, the test runner uses directory globs)
102
- - The `codex` CLI, authenticated (`codex --version`, `codex login`)
103
- - Claude Code, for the agents and the hook to be registered in
40
+ - Node.js >= 24
41
+ - An authenticated [Codex CLI](https://developers.openai.com/codex/cli): `codex --version` and `codex login`
42
+ - Claude Code, which hosts the agents, commands, and guards
104
43
 
105
- No runtime dependencies: everything here is plain `.mjs` on the Node standard library, and
106
- that is deliberate — installing from a clone stays one command, with no build step.
44
+ ## What it gives you
45
+
46
+ - **Runs survive the caller.** A detached worker keeps the run and its artifacts alive after the dispatcher exits.
47
+ - **Invalid work is rejected before quota is spent.** Scope, order, retry, branch, and continuation checks run before a run folder is created.
48
+ - **Verdicts come from artifacts.** CLI events, git state, and the declared report decide the result, not model claims about itself.
49
+ - **Identical retries attach safely.** Repeating the same command joins the active run instead of starting another one.
50
+ - **5 host guards enforce the boundary.** `reply-guard`, `order-gate`, `worktree-lock`, `prune-guard`, and `stop-guard` cover replies, orders, edits, deletion, and stopping.
51
+ - **Installation is identifiable and reversible.** Writes to `settings.json` are merged and backed up; uninstall removes recorded package files only.
52
+ - **Every run leaves an audit folder.** The verbatim task, scope, before/after git state, events, report, verdict, and reason remain together.
53
+ - **Zero runtime dependencies and zero build steps.** The package is plain `.mjs` on the Node.js standard library.
107
54
 
108
- ## Development
55
+ The current suite contains **604 automated tests: 603 passing and 1 skipped**.
56
+
57
+ ## Verify
58
+
59
+ ```bash
60
+ codex-bridge doctor
61
+ ```
62
+
63
+ Output on a healthy host (trimmed):
64
+
65
+ ```text
66
+ [ok] host: ~/.claude (user, exists)
67
+ [ok] installation: @lyupro/codex-bridge@0.5.1 (matches package)
68
+ [ok] files: 53 installed file(s) present
69
+ [ok] rules: ~/.codex/rules/codex-bridge.rules (matches record)
70
+ [ok] permissions: installed (24/24 own strings in allow/deny)
71
+ [ok] hook:SubagentStop: matcher * -> ~/.lyupro/.codex-bridge/hooks/reply-guard.mjs
72
+ [ok] hook:PreToolUse: matcher Agent|Task -> ~/.lyupro/.codex-bridge/hooks/order-gate.mjs
73
+ [ok] hook:PreToolUse: matcher Write|Edit|MultiEdit|NotebookEdit -> ~/.lyupro/.codex-bridge/hooks/worktree-lock.mjs
74
+ [ok] hook:PreToolUse: matcher Bash|PowerShell -> ~/.lyupro/.codex-bridge/hooks/prune-guard.mjs
75
+ [ok] hook:PreToolUse: matcher TaskStop -> ~/.lyupro/.codex-bridge/hooks/stop-guard.mjs
76
+ [warn] retention: Automatic cleanup is ON — run transport older than 30 days is removed to reclaim disk space.
77
+ [ok] conventions: ~/.lyupro/.codex-bridge/conventions.md (found)
78
+ [ok] codex: codex-cli 0.146.1
79
+ [ok] node: 24.18.0 (requires >=24)
80
+ [ok] runsRoot: ~/.claude/codex-runs
81
+ [ok] liveRuns: 0 runs working right now
82
+ ```
83
+
84
+ `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.
85
+
86
+ ## Update
87
+
88
+ Update the global package, then refresh the selected host installation:
89
+
90
+ ```bash
91
+ npm i -g @lyupro/codex-bridge
92
+ codex-bridge update
93
+ ```
94
+
95
+ `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.
96
+
97
+ ## Limitations
98
+
99
+ - **Runs are not isolated from one another.** They share one worktree; guards prevent concurrent writers instead of creating sandboxes.
100
+ - **A build run has a 25-minute deadline.** Work and artifacts survive a timeout, but the run may need an explicit continuation.
101
+ - **A dispatcher verdict is not the work outcome.** Judge completion from the worktree and tests as well as the recorded verdict.
102
+
103
+ 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.
104
+
105
+ ## Command reference
106
+
107
+ | Command | Purpose |
108
+ | --- | --- |
109
+ | `install [--scope user\|project] [--host <path>] [--dry-run] [--force]` | Install into a Claude Code host. |
110
+ | `update [--scope user\|project] [--host <path>] [--dry-run] [--force]` | Refresh a recorded installation. |
111
+ | `permissions [add\|remove] [--scope user\|project] [--host <path>]` | Inspect or manage optional shell rules. |
112
+ | `uninstall [--scope user\|project] [--host <path>] [--dry-run]` | Remove recorded package files while preserving run artifacts. |
113
+ | `doctor [--scope user\|project] [--host <path>]` | Diagnose the selected host and Codex connection. |
114
+ | `projects [<name>] [--json]` | List projects or runs in the run store. |
115
+ | `prune <project> [<run>] [--purge] [--older-than <age>] [-f] [--json]` | Plan or perform operator-confirmed cleanup. |
116
+ | `unlock [<project>\|--all]` | Close records whose runner is gone. |
117
+ | `read <run>` | Render a run's structured event stream. |
118
+ | `stop <run>` | Stop a run and record a `FAIL` verdict. |
119
+
120
+ Both `codex-bridge` and `codexb` invoke the same command. Run `codex-bridge --help` for exact forms.
121
+
122
+ ### Development
109
123
 
110
124
  ```bash
111
125
  git clone https://github.com/lyupro/codex-bridge.git
112
126
  cd codex-bridge
113
- git config core.hooksPath .githooks # pre-commit file-size gate, 400 lines per source file
114
- npm test # node --test, no dependencies to install
127
+ git config core.hooksPath .githooks
128
+ npm test
115
129
  ```
116
130
 
131
+ The Git hook enforces the 400-line source-file limit.
132
+
117
133
  ## License
118
134
 
119
- MIT
135
+ [MIT](LICENSE)
@@ -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
- const invokedDirectly =
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, 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,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(path.join(host.agentsDir, 'run-config.json')));
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 = path.join(host.agentsDir, 'conventions.md');
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
- 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) {
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 = 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}`;
129
164
  try {
130
165
  const state = await inspectHook(host.settingsPath, {
131
166
  event: definition.event,
132
167
  matcher: definition.matcher,
133
- command: commandFor(expected),
168
+ command: recorded.command || fullCommand,
169
+ alternateCommands: [fullCommand, shortCommand],
134
170
  });
135
171
  if (state.present) {
136
- 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)})`);
137
178
  }
138
- 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)})`);
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 relative of record.files) {
208
- 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}`);
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
+ }