@lyupro/codex-bridge 0.6.7 → 0.6.9

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 (101) hide show
  1. package/CHANGELOG.md +148 -0
  2. package/README.md +10 -6
  3. package/bin/codex-bridge.mjs +35 -172
  4. package/cli/command-help.mjs +49 -0
  5. package/cli/command-registry.mjs +284 -0
  6. package/cli/copy.mjs +42 -5
  7. package/cli/dispatcher-contract-record.mjs +21 -11
  8. package/cli/dispatcher-contract.mjs +6 -5
  9. package/cli/doctor-host-side.mjs +110 -0
  10. package/cli/doctor-installation.mjs +111 -111
  11. package/cli/doctor.mjs +35 -7
  12. package/cli/handback-witness-check.mjs +10 -18
  13. package/cli/home-inspection.mjs +122 -0
  14. package/cli/home-plan-execute.mjs +140 -0
  15. package/cli/home-removal-plan.mjs +61 -0
  16. package/cli/hook-recognizer.mjs +78 -0
  17. package/cli/host-contract.mjs +36 -21
  18. package/cli/host-inspection.mjs +156 -0
  19. package/cli/host-removal.mjs +91 -0
  20. package/cli/image-evidence.mjs +44 -0
  21. package/cli/install-owners.mjs +244 -0
  22. package/cli/install-record.mjs +71 -9
  23. package/cli/install.mjs +101 -77
  24. package/cli/inventory-removal.mjs +84 -0
  25. package/cli/inventory-transition.mjs +66 -0
  26. package/cli/lifecycle-lock-clear.mjs +104 -0
  27. package/cli/lifecycle-lock-inspect.mjs +91 -0
  28. package/cli/lifecycle-lock.mjs +335 -0
  29. package/cli/lifecycle-transaction.mjs +57 -0
  30. package/cli/link-segments.mjs +45 -0
  31. package/cli/model-set.mjs +4 -3
  32. package/cli/model-speed.mjs +9 -2
  33. package/cli/model.mjs +5 -3
  34. package/cli/owner-record.mjs +40 -0
  35. package/cli/owner-sync-apply.mjs +108 -0
  36. package/cli/owner-sync.mjs +115 -0
  37. package/cli/probe-contract.mjs +26 -10
  38. package/cli/probe-target.mjs +33 -0
  39. package/cli/projects.mjs +5 -2
  40. package/cli/prune-args.mjs +9 -4
  41. package/cli/prune.mjs +23 -72
  42. package/cli/purge-live-runs.mjs +98 -0
  43. package/cli/purge-preflight.mjs +103 -0
  44. package/cli/read.mjs +2 -1
  45. package/cli/record-removal.mjs +43 -0
  46. package/cli/removal-outcomes.mjs +115 -0
  47. package/cli/removal-plan.mjs +187 -0
  48. package/cli/remove-layout.mjs +40 -1
  49. package/cli/rules-owners.mjs +4 -2
  50. package/cli/session-hosts.mjs +51 -0
  51. package/cli/terminal-question.mjs +41 -0
  52. package/cli/uninstall-purge.mjs +139 -0
  53. package/cli/uninstall-rules.mjs +72 -0
  54. package/cli/uninstall.mjs +208 -126
  55. package/cli/unlock-lifecycle.mjs +102 -0
  56. package/cli/unlock.mjs +7 -4
  57. package/cli/update.mjs +102 -27
  58. package/package.json +4 -3
  59. package/scripts/home-write-audit.mjs +286 -0
  60. package/scripts/home-write-inventory.mjs +49 -0
  61. package/scripts/home-write-lexer.mjs +182 -0
  62. package/scripts/isolated-roots.mjs +6 -1
  63. package/scripts/open-work/check.mjs +180 -0
  64. package/scripts/open-work/live-thresholds.mjs +155 -0
  65. package/scripts/open-work/register.mjs +56 -0
  66. package/scripts/root-guard/root-entries.mjs +15 -0
  67. package/scripts/run-tests.mjs +33 -1
  68. package/src/agents/codex-advisor.md +5 -2
  69. package/src/home/conventions.md +9 -0
  70. package/src/home/hooks/dispatcher-gate.mjs +15 -2
  71. package/src/home/hooks/guard-tries.mjs +6 -4
  72. package/src/home/hooks/order-gate.mjs +2 -8
  73. package/src/home/hooks/reply-guard.mjs +24 -9
  74. package/src/home/hooks/worktree-witness.mjs +6 -6
  75. package/src/home/lib/agents.mjs +1 -1
  76. package/src/home/lib/atomic-json.mjs +24 -6
  77. package/src/home/lib/config-edit.mjs +25 -10
  78. package/src/home/lib/dispatcher-state.mjs +25 -10
  79. package/src/home/lib/file-lock.mjs +41 -10
  80. package/src/home/lib/handback-witness.mjs +33 -25
  81. package/src/home/lib/home-registry.mjs +211 -0
  82. package/src/home/lib/home-write.mjs +189 -0
  83. package/src/home/lib/hook-diagnostics.mjs +20 -0
  84. package/src/home/lib/host-observations.mjs +60 -0
  85. package/src/home/lib/host-version.mjs +27 -0
  86. package/src/home/lib/meta/advice-status.mjs +2 -2
  87. package/src/home/lib/meta/advice-verdict.mjs +30 -1
  88. package/src/home/lib/meta/events.mjs +2 -1
  89. package/src/home/lib/meta/paths.mjs +24 -24
  90. package/src/home/lib/meta/run-snapshots.mjs +29 -0
  91. package/src/home/lib/meta/snapshot-format.mjs +96 -0
  92. package/src/home/lib/meta/verdict.mjs +14 -16
  93. package/src/home/lib/run-config.mjs +10 -1
  94. package/src/home/lib/runner/git-paths.mjs +74 -0
  95. package/src/home/lib/runner/git-state.mjs +38 -34
  96. package/src/home/lib/runner/launcher.mjs +2 -2
  97. package/src/home/lib/runner/preflight.mjs +7 -0
  98. package/src/home/lib/runner/prompts.mjs +35 -2
  99. package/src/home/lib/runner/schemas.mjs +4 -1
  100. package/src/home/lib/runner/scope-check.mjs +29 -20
  101. package/src/home/lib/runner/worker.mjs +3 -3
package/CHANGELOG.md CHANGED
@@ -4,6 +4,154 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.6.9] - 2026-10-02
8
+
9
+ ### Added
10
+
11
+ - `uninstall --purge` can remove operator data and the home folder after one preflight under the lifecycle lock:
12
+ no live runs, a readable installation record and no other recorded owners, before permissions, hooks or files change.
13
+ A live run is named with its stop command; an unreadable run folder refuses because it cannot be proven idle.
14
+ - Purge asks separately whether the host inventory is complete and whether it may remove the listed operator data,
15
+ because a record cannot prove that nobody else uses the home and inventory consent is not deletion consent.
16
+ Without a terminal both answers are no; a refusal or cancellation changes nothing.
17
+ - `uninstall --purge --dry-run` reports every refusal together and lists removals under
18
+ `If you confirm both questions:`, so an inspection never implies consent. It takes no lock and asks no questions;
19
+ refusals and unsafe entries exit 1, while the two pending confirmations alone do not.
20
+ - The "Uninstall and purge" section in `docs/overview.md` explains removal and its limits, because a run can start
21
+ during purge and Node cannot prevent an ancestor being swapped between its check and removal.
22
+ - The installation record keeps one owner per host sharing the home, because installing a second host used to erase
23
+ the first host's record. A new home starts with a complete inventory; an old record cannot prove all its owners.
24
+ - Interactive install and update ask once whether this host is the home's only owner when migrating an old record
25
+ or recording a pre-existing image, because those transitions leave other users unknown. Uninstall asks again when
26
+ the last known owner of an incomplete inventory leaves; no keeps the image, and Ctrl+C cancels before changes.
27
+ - `doctor` prints `host files` and `home owners` lines showing package files and hooks, files uninstall would keep,
28
+ recorded hosts and inventory completeness, because "not installed" had hidden files in an unrecorded host.
29
+ - The `doctor` line `owners in sync` names hosts verified against an older shared image and gives their update commands,
30
+ because a warning printed only during install was easily lost. It reports recorded verification, not a fresh
31
+ inspection of those hosts, and an incomplete inventory never reads as all in sync.
32
+ - Install, update and uninstall take the home's lifecycle lock before their first read, because concurrent hosts
33
+ could interleave image copies and record writes. Windows uses a named pipe and Linux an abstract socket released
34
+ by the kernel when the holder dies; macOS uses a lock file requiring explicit recovery. Dry runs take no lock.
35
+ - `codex-bridge unlock --lifecycle` shows whether the home is free, held or unverified, with the holder's command,
36
+ host and pid when available, because a busy-home refusal previously pointed to a command that did not exist.
37
+ - `codex-bridge unlock --lifecycle --clear` removes only a proven-dead holder's macOS lock file, because automatic
38
+ takeover could remove a live or replacement holder's lock. Windows and Linux have no lock file to clear.
39
+ - Every command and its actions answer `-h` and `--help` from one command registry, because separate usage text had
40
+ omitted model actions and subcommands refused help. A flag that needs a value still refuses `--host --help`.
41
+ - The model table ends with `codex-bridge model set <role> <model> [effort]` and `codex-bridge model list`, because
42
+ the profiles alone did not tell the operator how to change one.
43
+
44
+ ### Changed
45
+
46
+ - Install and update bring other recorded hosts' package files and hooks along with the shared image, even when the
47
+ initiating host is already current, because those hosts had kept old agents without the guards the new image needs.
48
+ A host left behind is named with its update command and makes the command exit 1.
49
+ - Other hosts' edited, missing or linked files remain conflicts that the initiating host's `--force` cannot override,
50
+ because replacing the shared image must not erase another host's edits. Synchronization leaves permissions, seeds
51
+ and Codex rules ownership alone; unreachable host roots are named rather than created.
52
+ - `uninstall` removes only the departing host's owner entry while other hosts still use the home, because removing
53
+ the whole record and image had broken their installations. The shared image goes only after the last owner's
54
+ hooks are gone and the inventory is complete or the operator confirms nobody else uses it.
55
+ - The installer and uninstall touch only declared files in the package home, because undeclared writes and copied
56
+ removal lists had left files that removal could not account for.
57
+ - Ordinary `uninstall` and its dry run use one home removal plan, and the real summary counts actual outcomes and
58
+ distinguishes an owner removed from a record removed, because planned removals were reported as completed even
59
+ when files stayed. Folders are removed only when empty, never recursively.
60
+ - Ordinary `uninstall` names each operator file it keeps for `--purge`, such as `config.json` and `conventions.md`,
61
+ because a hand-written preservation sentence missed files. Runs in `codex-runs` remain outside both uninstall
62
+ and purge.
63
+ - Advisor default budgets are now 10 minutes for `scope` and 25 minutes for `advise`, because measured advise runs
64
+ approached or exceeded the previous 15-minute deadline.
65
+ - Worktree snapshots use format v2 with exact file names, because quoted and trimmed legacy names could change the
66
+ verdict. A legacy clean snapshot still reads as clean; missing, damaged or incompatible snapshot pairs fail the
67
+ verdict rather than count as a clean tree, including a run started before and judged after this format update.
68
+
69
+ ### Fixed
70
+
71
+ - `uninstall` recognizes host files and hooks by the package's marks and deletes files only when content matches
72
+ that host's recorded fingerprint or the current package, because edited agents were lost and unrecorded hosts
73
+ could not be cleaned safely. Edited, unknown and seed files, links and unreadable entries are kept and named.
74
+ - Host hooks are removed before host files, and a host whose hooks cannot be detached keeps its files, rules
75
+ ownership, record entry and shared image, because a broken `settings.json` had left hooks pointing at removed files.
76
+ - Edited image files stay and are named by uninstall and purge unless their content proves they are package files,
77
+ because recorded paths alone had authorized deletion. Links are never crossed or removed; unreadable entries are
78
+ kept, and a link, inspection error or removal failure exits 1 instead of claiming complete removal.
79
+ - Purge re-checks the home after removal, names files that appeared with `Appeared during purge:` and exits 1,
80
+ because a run started during purge can recreate a file after it was removed.
81
+ - Repeating `uninstall` for a home with no recorded owners asks whether anyone still uses it, because declining
82
+ removal once had left a home that the next uninstall incorrectly called "not installed".
83
+ - A migrated old record is no longer treated as every unrecorded host's own installation, because update and
84
+ uninstall had operated on another host's files. Install or update can record an unrecorded host from its
85
+ package marks.
86
+ - Install and update refresh the host's stale image verification even when its files are current, because the
87
+ "up to date" exit had left the `doctor` warning in place after the prescribed update.
88
+ - Command usage refusals point to the command's `-h`, including inside the JSON error from `prune --json`,
89
+ because malformed arguments had failed without showing where to find the correct syntax.
90
+ - `model speed` refuses dash arguments before reading config or fetching the catalogue and points to
91
+ `model speed -h`, because `model speed build --fast` had treated the flag as a tier identifier.
92
+ - Advisor prose containing clock times, ratios or host:port no longer fails as a file citation, because `00:05`
93
+ had been read as a file and line number. Invented paths and citations outside the allowed files still fail.
94
+ - Advisor recommendations can contain full text and paragraphs, because hidden length and line-break limits had
95
+ cut answers mid-citation or caused broken text during decoding. Remaining array bounds are stated in the prompt.
96
+ - Broken advisor text now fails with a named contract error, and reply previews preserve whole characters,
97
+ because a NUL-containing answer had passed and UTF-16 shortening could split an emoji.
98
+ - An advisor `scope` with `--continue` is refused before starting and asks for a new order id, because continuing
99
+ scope spent the order's only continuation and made its advise phase unreachable.
100
+ - Scope checks, snapshots and verdicts read Git file names byte-exact, so Cyrillic and other non-ASCII names work,
101
+ because Git's quoted output had refused valid scopes and judged in-scope edits outside them. Invalid UTF-8 names
102
+ fail instead of being silently replaced, and scope patterns with line breaks are refused before starting.
103
+ - An untracked file edit that keeps the same size now appears in the snapshot comparison, because recording size
104
+ alone had made such edits invisible; files that disappear or cannot be read are recorded as missing or unreadable.
105
+ - An unreadable `config.json` fails with its path and reason in the runner and `doctor`, because permission errors
106
+ or a directory in its place had silently selected default models and budgets. Only a missing file uses defaults.
107
+ - `prune` checks links from the trusted root down and keeps the target on any inspection error, because checking
108
+ from the leaf up had crossed links first and treated unreadable ancestors as permission to delete.
109
+
110
+ After `npm i -g @lyupro/codex-bridge@0.6.9`, run `codex-bridge update` once to migrate the installation record.
111
+ An interactive update asks once whether this host is the home's only owner; answer it, because it decides whether a
112
+ later uninstall may remove the shared image. Every host sharing the home should run 0.6.9: older CLI copies do not take
113
+ the lifecycle lock. `codex-bridge doctor` from 0.6.8 cannot read a record written by 0.6.9 and reports
114
+ `[fail] installation: broken record`; installing 0.6.9 clears that report.
115
+
116
+ ## [0.6.8] - 2026-09-25
117
+
118
+ ### Added
119
+
120
+ - Every session records the version of the host that runs its hooks, read from the `version` the
121
+ host writes into its own transcript, in `state/host-observations.json`. `doctor` prints a
122
+ `sessionHost` line and one `otherHost:<version>` line for each other host seen in the last 30
123
+ days. Sessions ran in the VS Code extension 2.1.282 while `claude` on PATH was 2.1.281, and the
124
+ extension's `CLAUDE_CODE_EXECPATH` and `CLAUDE_AGENT_SDK_VERSION` are inherited by every `claude`
125
+ it starts, so neither PATH nor the environment names the host.
126
+ - A subagent stop that carries an `agent_id` but no `agent_type` records a witness alarm and tells
127
+ the session that the dispatcher gate cannot recognise dispatchers. That was the one host change the
128
+ gate would otherwise have passed silently.
129
+
130
+ ### Changed
131
+
132
+ - `.host-contract.json` and `state/dispatcher-contract.json` keep one verdict per host version, so a
133
+ CLI session and an extension session on different versions no longer overwrite each other's
134
+ measurement. Existing single-version files are read as the record of their own version.
135
+ - `doctor --probe-contract` measures the executable of the newest observed host. `claude` on PATH
136
+ and `CLAUDE_CODE_EXECPATH` are used only when their own `--version` matches it;
137
+ `--probe-executable <path>` names one explicitly. A host that completes the probe but writes
138
+ another version into its transcript records nothing.
139
+
140
+ ### Fixed
141
+
142
+ - `doctor` and `install` judge the hosts sessions actually ran on instead of `claude` on PATH. A
143
+ machine whose sessions run in an editor extension had its contracts reported as verified for a
144
+ different program, and a permanent `handbackWitness` warning asked for a dispatcher run that could
145
+ never clear it. With no session observed yet, `doctor` says so rather than falling back to PATH.
146
+ - The handback witness records the host version from the transcript and compares it exactly. It had
147
+ matched an SDK version to a CLI version by patch number, which accepted unrelated versions sharing
148
+ a patch. An older SDK-based witness file reads as "not seen yet" and keeps its alarms.
149
+ - `uninstall` names the host observations among the files it leaves in the package home.
150
+
151
+ After installing 0.6.8, run `codex-bridge update` once so the home receives the observation code,
152
+ then run any shell command in a session. If `doctor` reports the session host as probed on another
153
+ version, run `codex-bridge doctor --probe-contract` once (about two minutes of Claude quota).
154
+
7
155
  ## [0.6.7] - 2026-09-25
8
156
 
9
157
  ### Added
package/README.md CHANGED
@@ -62,7 +62,7 @@ If a global install and a clone coexist, you have two package copies. `codex-bri
62
62
  - **Every run leaves an audit folder.** The verbatim task, scope, before/after git state, events, report, verdict, and reason remain together.
63
63
  - **Zero runtime dependencies and zero build steps.** The package is plain `.mjs` on the Node.js standard library.
64
64
 
65
- The current suite contains **1636 automated tests: 1635 passing and 1 skipped**.
65
+ The current suite contains **2570 automated tests: 2569 passing and 1 skipped**.
66
66
 
67
67
  ## Verify
68
68
 
@@ -91,7 +91,9 @@ Output on a healthy host (trimmed):
91
91
  [ok] liveRuns: 0 runs working right now
92
92
  ```
93
93
 
94
- `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. It also reports `handbackWitness` and the four dispatcher contract lines (`dispatcherContract:agentIdentity`, `dispatcherContract:shellStdout`, `dispatcherContract:shellFailure`, and `dispatcherContract:agentTranscript`): verified contracts are `ok`, unknown, unprobed, or stale contracts are `warn`, and a measured change is `fail` with exit 1. The `agents` line is a read, not a file count: each installed definition is parsed and its `name` checked against the dispatcher it claims to be, because a definition the host cannot read is a dispatcher that does not exist while every other line still says `[ok]`. Content that drifts from the packaged definition is a failure, not a warning: `doctor` exits 1 and names `codex-bridge update --force`, because a host running someone else's agent files may be calling the runner in a form no permission rule matches.
94
+ `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. It judges contracts against the newest host version observed in a session transcript, not `claude` on `PATH`: on 2026-09-25 the VS Code extension ran sessions on 2.1.282 while `PATH` resolved to 2.1.281, and the extension's `CLAUDE_CODE_EXECPATH` and `CLAUDE_AGENT_SDK_VERSION` were inherited by every descendant `claude`. The transcript entry's `version` identifies the host. The dispatcher gate records it once per session in `state/host-observations.json` (sessions kept 7 days, hosts 30); `doctor` and `install` judge the newest observed host. `doctor` prints `sessionHost` and one `otherHost:<version>` line for each other observed version, with `ok`, `warn` and a probe command, or `fail` with exit 1. If no session has been observed, they say so instead of using `PATH`. Refusal and dispatcher contract records keep one verdict per host version, so a measurement of version V applies to every host of version V; older single-version records count only for their recorded version.
95
+
96
+ `doctor` also reports `handbackWitness` and the four dispatcher contract lines (`dispatcherContract:agentIdentity`, `dispatcherContract:shellStdout`, `dispatcherContract:shellFailure`, and `dispatcherContract:agentTranscript`): verified contracts are `ok`, unknown, unprobed, or stale contracts are `warn`, and a measured change is `fail` with exit 1. Handback sightings and alarms carry the transcript version, and sightings match only that exact version; an older SDK-based record counts as no sighting while preserving its alarms. If a subagent stop has an `agent_id` but no `agent_type`, the reply guard records an alarm and tells the session that the dispatcher gate cannot recognise dispatchers. The `agents` line is a read, not a file count: each installed definition is parsed and its `name` checked against the dispatcher it claims to be, because a definition the host cannot read is a dispatcher that does not exist while every other line still says `[ok]`. Content that drifts from the packaged definition is a failure, not a warning: `doctor` exits 1 and names `codex-bridge update --force`, because a host running someone else's agent files may be calling the runner in a form no permission rule matches.
95
97
 
96
98
  If a session answers `Agent type 'codex-build' not found`, a run can still be started without the dispatcher — the agent is a wrapper that types one command:
97
99
 
@@ -127,17 +129,19 @@ Transport files from runs at least **30 days** old are pruned automatically when
127
129
  | `install [--scope user\|project] [--host <path>] [--dry-run] [--force]` | Install into a Claude Code host. |
128
130
  | `update [--scope user\|project] [--host <path>] [--dry-run] [--force]` | Refresh a recorded installation. |
129
131
  | `permissions [add\|remove] [--scope user\|project] [--host <path>]` | Inspect or manage optional shell rules. |
130
- | `uninstall [--scope user\|project] [--host <path>] [--dry-run]` | Remove recorded package files while preserving run artifacts. |
131
- | `doctor [--scope user\|project] [--host <path>] [--probe-contract]` | Diagnose the selected host and Codex connection, including the handback witness and four dispatcher contract lines. `--probe-contract` measures refusal and dispatcher contracts in one live host run and records them when the host completes it. |
132
+ | `uninstall [--scope user\|project] [--host <path>] [--dry-run] [--purge]` | Remove recorded package files while preserving run artifacts; `--purge` also deletes your data and the home after one preflight and two consents (see [Uninstall and purge](docs/overview.md#uninstall-and-purge)). |
133
+ | `doctor [--scope user\|project] [--host <path>] [--probe-contract] [--probe-executable <path>]` | Diagnose the selected host and Codex connection, including the handback witness and four dispatcher contract lines. `--probe-contract` targets the newest observed host, accepting `CLAUDE_CODE_EXECPATH` or `claude` on `PATH` only when each reports that version with `--version`; use `--probe-executable <path>` to name an executable (`--host` selects the Claude Code configuration). The probe prints its target and records nothing if the completed host run reports another transcript version. |
132
134
  | `run <runner options> --task-file <abs path>` | Start or attach to a delegated run through the permission-stable package command. The task file carries the statement, the scout questions and the verification command; its path must be absolute. Piping the statement on stdin is the alternative channel, and the two cannot be combined. |
133
135
  | `model [list\|set\|unset\|speed]` | Show the model, effort, pinned speed tier and provenance of each role, with the config path; `model list` prints the live catalogue from Codex, hidden models included and marked; `model set <role> <model> [effort]` checks the pair and any retained speed tier against that model's catalogue entry, and `model unset <role>` removes the profile. `model speed <role> <tier>` previews a live accelerated tier and its quoted cost description; repeat with trailing `confirm` to pin it. `model speed <role> unset` removes only speed, without confirmation. The profile is machine-wide, shared by every project on this machine. |
134
136
  | `projects [<name>] [--json]` | List projects or runs in the run store. |
135
137
  | `prune <project> [<run>] [--purge] [--older-than <age>] [-f] [--json]`<br>`prune --all-projects [--older-than <age>] [-f] [--json]` | Plan or perform operator-confirmed cleanup, for one project or across the whole run store. |
136
- | `unlock [<project>\|--all]` | Close records whose runner is gone. |
138
+ | `unlock [<project>\|--all]`<br>`unlock --lifecycle [--clear]` | Close records whose runner is gone, or show who holds the lock that serializes install, update and uninstall of this home; `--clear` removes that lock only when it is a file left by a holder proven dead (macOS; Windows and Linux locks are released by the kernel). |
137
139
  | `read <run>` | Render a run's structured event stream. |
138
140
  | `stop <run>` | Stop a run and record a `FAIL` verdict. |
139
141
 
140
- Both `codex-bridge` and `codexb` invoke the same command. Run `codex-bridge --help` for exact forms.
142
+ Both `codex-bridge` and `codexb` invoke the same command. Run `codex-bridge --help` for exact forms, or `-h` after
143
+ any command or action for its own usage (`codex-bridge model -h`, `codex-bridge model set -h`); a refusal of an unknown
144
+ option or an extra argument ends with that pointer.
141
145
 
142
146
  ### Development
143
147
 
@@ -1,87 +1,33 @@
1
1
  #!/usr/bin/env node
2
- /** Dispatches codex-bridge CLI arguments to focused command modules. */
3
- import { diagnose, renderDoctor } from '../cli/doctor.mjs';
4
- import { probeContract } from '../cli/probe-contract.mjs';
2
+ /** Dispatches codex-bridge CLI arguments through the command registry (Plan_71 D1). */
5
3
  import { isInvokedDirectly } from '../cli/invoked-directly.mjs';
6
- import { hook } from '../cli/hook.mjs';
7
- import { resolveHost } from '../cli/hosts.mjs';
8
- import { install } from '../cli/install.mjs';
9
- import { model } from '../cli/model.mjs';
10
- import { projects } from '../cli/projects.mjs';
11
- import { read } from '../cli/read.mjs';
12
- import { runCodex } from '../cli/run-launcher.mjs';
13
4
  import { packageInfo } from '../cli/manifest.mjs';
14
- import { permissions } from '../cli/permissions.mjs';
15
- import { prune } from '../cli/prune.mjs';
16
- import { stop } from '../cli/stop.mjs';
17
- import { unlock } from '../cli/unlock.mjs';
18
- import { uninstall } from '../cli/uninstall.mjs';
19
- import { update } from '../cli/update.mjs';
20
-
21
- export const HELP = `codex-bridge — Claude Code dispatchers for Codex
22
-
23
- Usage:
24
- codex-bridge install [--scope user|project] [--host <path>] [--dry-run] [--force]
25
- codex-bridge update [--scope user|project] [--host <path>] [--dry-run] [--force]
26
- codex-bridge permissions [add|remove] [--scope user|project] [--host <path>]
27
- codex-bridge uninstall [--scope user|project] [--host <path>] [--dry-run]
28
- codex-bridge doctor [--scope user|project] [--host <path>] [--probe-contract]
29
- codex-bridge run <runner options> --task-file <path>
30
- codex-bridge model [list]
31
- codex-bridge projects [<name>] [--json]
32
- codex-bridge prune <project> [<run>] [--purge] [--older-than <age>] [-f] [--json]
33
- codex-bridge prune --all-projects [--older-than <age>] [-f] [--json]
34
- codex-bridge unlock [<project>|--all]
35
- codex-bridge read <run>
36
- codex-bridge stop <run>
37
- codex-bridge hook <name>
38
- codexb <same command forms as codex-bridge>
39
- codex-bridge --help
40
- codex-bridge --version
41
-
42
- Commands:
43
- install Install codex-bridge into the selected Claude Code host
44
- update Update a recorded codex-bridge installation
45
- permissions Show, add, or remove optional shell permission rules
46
- uninstall Remove installed files while preserving run artifacts
47
- doctor Diagnose the selected Claude Code host (--probe-contract measures on a live host)
48
- run Start or attach to a delegated Codex run
49
- model Show machine-wide model profiles or list the live catalogue
50
- projects List projects or runs from the run store
51
- prune Remove archived transport, or purge selected run folders
52
- unlock Close running records whose runner is gone
53
- read Render a run's structured event stream
54
- stop Stop a running Codex run and record FAIL
55
-
56
- Hook dispatch:
57
- codex-bridge hook <name> Dispatch a registered guard with stdin unchanged`;
58
-
59
- const HOOK_COMMAND = 'hook';
60
-
61
- export function commandOptions(command, argv) {
62
- const options = {};
63
- const booleanFlags = command === 'install' || command === 'update' ? new Set(['--dry-run', '--force'])
64
- : command === 'uninstall' ? new Set(['--dry-run'])
65
- : command === 'doctor' ? new Set(['--probe-contract']) : new Set();
66
- const flagNames = new Map([
67
- ['--dry-run', 'dryRun'],
68
- ['--force', 'force'],
69
- ['--probe-contract', 'probeContract'],
70
- ]);
71
- for (let index = 0; index < argv.length; index += 1) {
72
- const arg = argv[index];
73
- if (booleanFlags.has(arg)) {
74
- options[flagNames.get(arg)] = true;
75
- continue;
76
- }
77
- if (arg !== '--scope' && arg !== '--host') throw new Error(`unknown ${command} option "${arg}"`);
78
- const value = argv[index + 1];
79
- if (!value || value.startsWith('-')) throw new Error(`${arg} requires a value`);
80
- options[arg === '--scope' ? 'scope' : 'host'] = value;
81
- index += 1;
82
- }
83
- return options;
84
- }
5
+ import { COMMANDS } from '../cli/command-registry.mjs';
6
+ import { helpRequest, renderCommandHelp } from '../cli/command-help.mjs';
7
+
8
+ export { commandOptions } from '../cli/command-registry.mjs';
9
+
10
+ const publicCommands = COMMANDS.filter((entry) => entry.section === 'public');
11
+ const commandWidth = Math.max(...publicCommands.map((entry) => entry.name.length));
12
+ export const HELP = [
13
+ 'codex-bridge — Claude Code dispatchers for Codex',
14
+ '',
15
+ 'Usage:',
16
+ ...publicCommands.flatMap((entry) => [entry, ...Object.values(entry.actions ?? {})]
17
+ .flatMap((section) => section.usage.map((line) => ` ${line}`))),
18
+ ' codexb <same command forms as codex-bridge>',
19
+ ' codex-bridge --help',
20
+ ' codex-bridge --version',
21
+ '',
22
+ 'Commands:',
23
+ ...publicCommands.map((entry) => ` ${entry.name.padEnd(commandWidth)} ${entry.summary}`),
24
+ '',
25
+ 'Hook dispatch:',
26
+ ...COMMANDS.filter((entry) => entry.section === 'machine')
27
+ .flatMap((entry) => entry.usage.map((line) => ` ${line} ${entry.summary}`)),
28
+ '',
29
+ 'Run codex-bridge <command> -h for one command.',
30
+ ].join('\n');
85
31
 
86
32
  export async function main(argv, io = console) {
87
33
  const [command, ...rest] = argv;
@@ -93,100 +39,17 @@ export async function main(argv, io = console) {
93
39
  io.log((await packageInfo()).version);
94
40
  return 0;
95
41
  }
96
- if (command === 'run') return runCodex(rest);
97
- if (command === 'doctor') {
98
- const options = commandOptions(command, rest);
99
- const host = resolveHost(options);
100
- // The probe runs BEFORE the diagnosis so the `hostContract` line below carries the verdict just
101
- // measured. Printing the diagnosis first and the measurement after would answer one question
102
- // twice in one output, with the older answer on top (Plan_52 D25).
103
- let probe = null;
104
- if (options.probeContract) {
105
- probe = await probeContract({ host });
106
- io.log(`probe: ${probe.message}`);
107
- for (const [name, verdict] of Object.entries(probe.dispatcher ?? {})) {
108
- io.log(`probe: ${name} ${verdict.result} — ${verdict.detail}`);
109
- }
110
- }
111
- const result = await diagnose({ host });
112
- io.log(renderDoctor(result));
113
- // An inconclusive probe wrote nothing and measured nothing; exiting 0 would let a failed
114
- // measurement pass silently in a script (Plan_52 D26).
115
- return probe && probe.state === 'inconclusive' ? 2 : result.exitCode;
116
- }
117
- if (command === 'stop') {
118
- if (rest.length !== 1) {
119
- io.error('codex-bridge stop requires exactly one run folder (full path or bare name).');
120
- return 2;
121
- }
122
- const result = await stop({ run: rest[0] });
123
- io.log(result.output);
124
- return result.exitCode;
125
- }
126
- if (command === HOOK_COMMAND) return hook(rest, io);
127
- if (command === 'read') {
128
- if (rest.length !== 1) {
129
- io.error('codex-bridge read requires exactly one run folder (full path or bare name).');
130
- return 2;
131
- }
132
- const result = read({ run: rest[0] });
133
- io.log(result.output);
134
- return result.exitCode;
135
- }
136
- if (command === 'projects') {
137
- const result = projects(rest);
138
- io.log(result.output);
139
- return result.exitCode;
140
- }
141
- if (command === 'model') {
142
- const result = await model(rest);
143
- io.log(result.output);
144
- return result.exitCode;
145
- }
146
- if (command === 'prune') {
147
- const result = await prune(rest);
148
- io.log(result.output);
149
- return result.exitCode;
150
- }
151
- if (command === 'sweep') {
152
- io.error('codex-bridge sweep was renamed to codex-bridge unlock; use the new command.');
42
+ const entry = COMMANDS.find((candidate) => candidate.name === command);
43
+ if (!entry) {
44
+ io.error(`codex-bridge: unknown command "${command}"\nRun codex-bridge --help for usage.`);
153
45
  return 2;
154
46
  }
155
- if (command === 'unlock') {
156
- const result = unlock(rest);
157
- io.log(result.output);
158
- return result.exitCode;
159
- }
160
- if (command === 'permissions') {
161
- let action;
162
- let optionArgs = rest;
163
- if (rest[0] && !rest[0].startsWith('-')) {
164
- action = rest[0];
165
- optionArgs = rest.slice(1);
166
- }
167
- if (action && !['add', 'remove'].includes(action)) {
168
- throw new Error(`unknown permissions action "${action}"`);
169
- }
170
- const options = commandOptions(command, optionArgs);
171
- const host = resolveHost(options);
172
- const result = await permissions({ host, action });
173
- io.log(result.output);
174
- return result.exitCode;
175
- }
176
- if (command === 'install' || command === 'update' || command === 'uninstall') {
177
- const options = commandOptions(command, rest);
178
- const host = resolveHost(options);
179
- const handlers = {
180
- install: () => install({ host, dryRun: options.dryRun, force: options.force }),
181
- update: () => update({ host, dryRun: options.dryRun, force: options.force }),
182
- uninstall: () => uninstall({ host, dryRun: options.dryRun }),
183
- };
184
- const result = await handlers[command]();
185
- io.log(result.output);
186
- return result.exitCode;
47
+ const asked = helpRequest(rest, Object.keys(entry.actions ?? {}));
48
+ if (asked && entry.section !== 'renamed') {
49
+ io.log(renderCommandHelp(entry, asked.action));
50
+ return 0;
187
51
  }
188
- io.error(`codex-bridge: unknown command "${command}"\nRun codex-bridge --help for usage.`);
189
- return 2;
52
+ return entry.handler(rest, io);
190
53
  }
191
54
 
192
55
  if (isInvokedDirectly(process.argv[1], import.meta.url)) {
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Decides what a help request is and how one command's help reads (Plan_71 D1).
3
+ * `codex-bridge model -h` answered "unexpected argument" and no subcommand answered -h at all
4
+ * (2026-09-29), so the operator could not learn how to set a model. Only exact argv shapes count
5
+ * (advice A1): a scan for --help would turn `--host --help` — today a "requires a value" refusal —
6
+ * into a help answer and hide the parser's error.
7
+ */
8
+
9
+ export function helpRequest(argv, actions = []) {
10
+ const isHelp = (value) => value === '-h' || value === '--help';
11
+ if (argv.length === 1 && isHelp(argv[0])) return { action: null };
12
+ if (argv.length === 2 && actions.includes(argv[0]) && isHelp(argv[1])) {
13
+ return { action: argv[0] };
14
+ }
15
+ return null;
16
+ }
17
+
18
+ export function withCommandHelp(command, message) {
19
+ const hint = `Run codex-bridge ${command} -h for usage.`;
20
+ if (message === hint || message.endsWith(`\n${hint}`)) return message;
21
+ return `${message}\n${hint}`;
22
+ }
23
+
24
+ export function renderCommandHelp(entry, action = null) {
25
+ const prefix = `codex-bridge ${entry.name}`;
26
+ const actions = Object.entries(entry.actions ?? {});
27
+ // Registry typos must fail even when the malformed action is not the requested one.
28
+ for (const section of [entry, ...actions.map(([, details]) => details)]) {
29
+ for (const line of section.usage) {
30
+ if (line !== prefix && !line.startsWith(`${prefix} `)) {
31
+ throw new Error(`Help usage must start with "${prefix}": ${line}`);
32
+ }
33
+ }
34
+ }
35
+
36
+ let section = entry;
37
+ if (action !== null) {
38
+ const match = actions.find(([name]) => name === action);
39
+ if (!match) throw new Error(`Unknown help action for ${entry.name}: ${action}`);
40
+ section = match[1];
41
+ }
42
+ const lines = ['Usage:', ...section.usage.map((line) => ` ${line}`), '', section.summary];
43
+ if (action === null && entry.actions) {
44
+ const width = Math.max(...actions.map(([name]) => name.length));
45
+ lines.push('', 'Actions:', ...actions.map(([name, details]) => ` ${name.padEnd(width)} ${details.summary}`));
46
+ lines.push(`Run codex-bridge ${entry.name} <action> -h for one action.`);
47
+ }
48
+ return lines.join('\n');
49
+ }