@lyupro/codex-bridge 0.3.0 → 0.4.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 (45) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +27 -7
  3. package/bin/codex-bridge.mjs +28 -4
  4. package/cli/doctor.mjs +53 -3
  5. package/cli/install.mjs +8 -2
  6. package/cli/manifest.mjs +24 -12
  7. package/cli/permissions.mjs +179 -0
  8. package/cli/projects.mjs +17 -4
  9. package/cli/prune-args.mjs +1 -1
  10. package/cli/rules-owners.mjs +6 -8
  11. package/cli/runs-inventory.mjs +2 -1
  12. package/cli/settings-merge.mjs +93 -45
  13. package/cli/stop.mjs +33 -20
  14. package/cli/uninstall.mjs +23 -10
  15. package/cli/unlock.mjs +239 -0
  16. package/package.json +3 -2
  17. package/scripts/check-file-size.mjs +8 -10
  18. package/src/agents/codex-build.md +2 -0
  19. package/src/agents/codex-review.md +2 -0
  20. package/src/agents/codex-scout.md +2 -0
  21. package/src/cli-names.mjs +6 -0
  22. package/src/conventions.md +44 -0
  23. package/src/heartbeat.mjs +1 -1
  24. package/src/hooks/live-runs.mjs +15 -14
  25. package/src/hooks/order-gate.mjs +7 -2
  26. package/src/hooks/prune-guard.mjs +12 -4
  27. package/src/hooks/reply-guard.mjs +4 -3
  28. package/src/json-file.mjs +30 -0
  29. package/src/meta/chain.mjs +8 -6
  30. package/src/meta/paths.mjs +5 -9
  31. package/src/meta/pre-start.mjs +30 -0
  32. package/src/meta/run-state.mjs +31 -16
  33. package/src/process-identity.mjs +140 -0
  34. package/src/required-inputs.mjs +42 -0
  35. package/src/run-config.mjs +4 -8
  36. package/src/runner/args.mjs +16 -2
  37. package/src/runner/attach.mjs +23 -17
  38. package/src/runner/codex-cmd.mjs +1 -1
  39. package/src/runner/conventions.mjs +39 -0
  40. package/src/runner/launcher.mjs +43 -16
  41. package/src/runner/project-dir.mjs +4 -3
  42. package/src/runner/scope-check.mjs +159 -0
  43. package/src/runner/worker.mjs +13 -5
  44. package/src/write-meta.mjs +8 -1
  45. package/cli/sweep.mjs +0 -144
package/CHANGELOG.md CHANGED
@@ -4,6 +4,66 @@ 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.4.0] - 2026-08-09
10
+
11
+ ### Added
12
+
13
+ - Scope patterns are checked before Codex starts. A pattern that cannot match anything — an
14
+ absolute or drive-qualified path, backslash separators, a `..` segment, or a spelling no
15
+ repository file answers to — is refused with no run folder and no quota spent, for all three
16
+ dispatchers. The file list comes from `git ls-files --cached --others --exclude-standard`, so
17
+ ignored output does not count as repository content. A file the run is meant to create is
18
+ declared with `--scope-new`, accepted only by `codex-build` and recorded in `worker.json`.
19
+ - `codex-bridge permissions` reports, adds, and removes the optional shell permission rules for
20
+ both CLI names and the clone entry point. The exact allow and deny strings merge into
21
+ `settings.json`, preserve foreign rules, and are also removed by `uninstall`; `doctor` reports
22
+ an absent or partial set as an optional warning.
23
+
24
+ ### Changed
25
+
26
+ - `codex-bridge sweep` is now `codex-bridge unlock`. The default scope is the current repository;
27
+ a bare project name remains addressable, and `--all` is now required for the whole store. The
28
+ report shows age, silence, and `alive` / `dead` / `foreign` / `unverified` identity verdicts;
29
+ confirmed-alive records are refused with `codex-bridge stop <run>` instead of being closed.
30
+
31
+ ### Fixed
32
+
33
+ - Run records now verify that a pid still belongs to the process that started the run, using the
34
+ process start time and a fresh heartbeat before falling back to an OS probe. A reused or
35
+ unverified pid is never signalled by `stop`; `codex-bridge unlock` remains the non-killing way to
36
+ close a record whose process identity cannot be confirmed.
37
+ - JSON files written by PowerShell with a UTF-8 BOM are now read consistently by the CLI and
38
+ installed hooks, and one command run creates at most one settings backup without deleting older
39
+ backups.
40
+ - 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.
41
+ - `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.
42
+ - `uninstall --dry-run` names each hook by its own matcher. Three of the four hooks are PreToolUse,
43
+ and a lookup by event alone reported `Agent|Task` for the worktree lock and the prune guard too —
44
+ in the one line an operator reads to see what a removal is about to touch. The removal itself was
45
+ never affected: hooks are found by command, not by matcher.
46
+ - The order gate's refusal now shows the line it found. A caller who wrote
47
+ `scope (you may create/modify ONLY these):` was told scope was missing, re-read their own prompt,
48
+ saw it written there, and had nothing to act on; an autonomous run rewrites the same text forever.
49
+ The refusal names the candidate line under the input it belongs to and says why it was not
50
+ accepted — a qualifier between the label and the colon, a value that is still a placeholder, or no
51
+ separator at all — and stays silent when no candidate exists, because then "the input is missing"
52
+ is the truth. The parser accepts exactly what it accepted before: deriving scope from prose would
53
+ give away the only guarantee that a run cannot touch files nobody listed.
54
+
55
+ ## [0.3.1] - 2026-08-07
56
+
57
+ ### Added
58
+
59
+ - The CLI answers to `codexb` as well as `codex-bridge`, and README now documents the global install that puts both names on `PATH`. Every spelling that can reach deletion comes from one frozen list, and the prune guard builds its matcher from that list rather than repeating the names — two independent lists drift silently, which is exactly how the installer and test hook lists had already drifted. The full name stays primary: the docs, checklists and three agent prompts are written with it.
60
+ - Every run carries the rules it is judged by. A seeded `conventions.md` beside `run-config.json` and an optional `.codex-conventions.md` in the repository being worked on are pasted into `task.md` verbatim, under one `## Conventions` section. Either file missing is not an error, and a blank one produces no section at all — rules handed over as an empty heading read as rules ignored. Until now project rules reached a run only when the orchestrator remembered to write "read CLAUDE.md" into the task, and six acceptance passes in a row caught the same class of violation: the rule existed, applied, and had not travelled. Pasting rather than referencing is the point — "read file X" is hope, and a run is free not to.
61
+ - `doctor` opens with the path of the package copy answering, marked `clone` or `installed package`, and reports whether the host conventions file was found, warning when it exists but is empty. A global install puts a second copy of the package beside any clone, and `update` copies host files from whichever copy launched it — so two diagnoses of the same host were previously indistinguishable.
62
+
63
+ ### Fixed
64
+
65
+ - `codex-bridge projects` printed the `last run` column on two clocks. Folder names are written in local time and `meta.finished_at` is an ISO stamp in UTC, and each reached the column through its own branch, so a run whose folder says `144243` was listed as `12:46` — the operator's own zone offset, indistinguishable from a different run. Which run is newest was never affected, only how it printed.
66
+
7
67
  ## [0.3.0] - 2026-08-07
8
68
 
9
69
  ### Added
package/README.md CHANGED
@@ -7,19 +7,29 @@ subscription instead of the Claude one.
7
7
  ## Install
8
8
 
9
9
  ```bash
10
- npx @lyupro/codex-bridge install # into ~/.claude
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
11
14
  node bin/codex-bridge.mjs install # from a clone, same thing
12
15
  node bin/codex-bridge.mjs install --scope project # into <repo>/.claude
13
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
14
20
  node bin/codex-bridge.mjs doctor # what is installed, where it points, is codex alive
15
21
  node bin/codex-bridge.mjs uninstall # removes exactly what was installed
16
22
  node bin/codex-bridge.mjs stop <run> # close a hanging run by hand, without hunting for a pid
17
23
  node bin/codex-bridge.mjs read <run> # read a run: its events rendered as text, on demand
18
24
  node bin/codex-bridge.mjs projects # what the run store holds: projects, runs, weight, tokens
19
25
  node bin/codex-bridge.mjs prune <project> # reclaim disk space, transport only unless told otherwise
20
- node bin/codex-bridge.mjs sweep [<project>] # close running records whose runner is gone
26
+ node bin/codex-bridge.mjs unlock [<project>|--all] # close records whose runner is gone
21
27
  ```
22
28
 
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
+
23
33
  `install` copies the package into the host's `agents/codex/`, its slash commands into
24
34
  `commands/codex/`, and registers its four hooks in `settings.json` — **merged**, so hooks that
25
35
  are already there survive, and the file is backed up before every write. Run it twice and the
@@ -31,6 +41,13 @@ version: outdated files are refreshed silently, files you changed by hand stop t
31
41
  named, and `--force` is what overwrites them. A file the package no longer ships is removed only
32
42
  if you never touched it.
33
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
+
34
51
  `uninstall` removes only what the install recorded: a file you put in `agents/codex/` yourself
35
52
  stays, and `codex-runs/` is never touched — those are your run artifacts, not the package.
36
53
 
@@ -55,11 +72,14 @@ accounting, reports and worktree snapshots alone; `--purge` is what takes a fold
55
72
  The same transport is dropped automatically from runs older than 30 days when a new run starts,
56
73
  which the reply says out loud; `retention` in `run-config.json` changes the age or turns it off.
57
74
 
58
- `sweep` is housekeeping for records left by a dead runner. With no project it scans the whole
59
- store; with one project it limits the scan to that folder. It uses the same dead-pid decision as
60
- start-of-run cleanup, never closes a live pid even when its heartbeat is stale, and never deletes
61
- run artifacts. A stale live-pid record is named with the exact `codex-bridge stop <run>` command
62
- needed to close it.
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.
63
83
 
64
84
  ## What it gives you
65
85
 
@@ -8,9 +8,10 @@ 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,25 +20,28 @@ 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
+ codexb <same command forms as codex-bridge>
30
33
  codex-bridge --help
31
34
  codex-bridge --version
32
35
 
33
36
  Commands:
34
37
  install Install codex-bridge into the selected Claude Code host
35
38
  update Update a recorded codex-bridge installation
39
+ permissions Show, add, or remove optional shell permission rules
36
40
  uninstall Remove installed files while preserving run artifacts
37
41
  doctor Diagnose the selected Claude Code host
38
42
  projects List projects or runs from the run store
39
43
  prune Remove archived transport, or purge selected run folders
40
- sweep Close running records whose runner is gone
44
+ unlock Close running records whose runner is gone
41
45
  read Render a run's structured event stream
42
46
  stop Stop a running Codex run and record FAIL`;
43
47
 
@@ -109,7 +113,27 @@ export async function main(argv, io = console) {
109
113
  return result.exitCode;
110
114
  }
111
115
  if (command === 'sweep') {
112
- const result = sweep(rest);
116
+ io.error('codex-bridge sweep was renamed to codex-bridge unlock; use the new command.');
117
+ return 2;
118
+ }
119
+ if (command === 'unlock') {
120
+ const result = unlock(rest);
121
+ io.log(result.output);
122
+ return result.exitCode;
123
+ }
124
+ if (command === 'permissions') {
125
+ let action;
126
+ let optionArgs = rest;
127
+ if (rest[0] && !rest[0].startsWith('-')) {
128
+ action = rest[0];
129
+ optionArgs = rest.slice(1);
130
+ }
131
+ if (action && !['add', 'remove'].includes(action)) {
132
+ throw new Error(`unknown permissions action "${action}"`);
133
+ }
134
+ const options = commandOptions(command, optionArgs);
135
+ const host = resolveHost(options);
136
+ const result = await permissions({ host, action });
113
137
  io.log(result.output);
114
138
  return result.exitCode;
115
139
  }
package/cli/doctor.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  /** Diagnoses a Claude Code host without modifying it. */
2
2
  import fs from 'node:fs/promises';
3
3
  import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
4
5
  import { spawnSync } from 'node:child_process';
5
6
  import {
6
7
  fileFingerprint,
@@ -8,6 +9,7 @@ import {
8
9
  readInstallRecord,
9
10
  packageInfo,
10
11
  } from './manifest.mjs';
12
+ import { inspectPermissions } from './permissions.mjs';
11
13
  import { commandFor, inspectHook } from './settings-merge.mjs';
12
14
  import { readRulesRegistry } from './rules-owners.mjs';
13
15
  import { readRunConfig, retentionNotice } from '../src/run-config.mjs';
@@ -47,6 +49,34 @@ function retentionCheck(host) {
47
49
  }
48
50
  }
49
51
 
52
+ async function conventionsCheck(host) {
53
+ const file = path.join(host.agentsDir, 'conventions.md');
54
+ let content;
55
+ try {
56
+ content = await fs.readFile(file, 'utf8');
57
+ } catch (err) {
58
+ if (err.code === 'ENOENT') return check('conventions', 'ok', `${file} (not found; optional)`);
59
+ return check('conventions', 'fail', `cannot read ${file}: ${err.message}`);
60
+ }
61
+ return content.trim()
62
+ ? check('conventions', 'ok', `${file} (found)`)
63
+ : check('conventions', 'warn', `${file} (found but empty)`);
64
+ }
65
+
66
+ /**
67
+ * Which copy of the package is answering. Plan_19 gives the CLI a second name, and a global install
68
+ * puts a second copy of the package on the machine beside any clone. `update` copies host files
69
+ * from whichever copy launched it, so `codexb update` from PATH silently reverts a host that
70
+ * `npm run dev:install` from the clone had just refreshed — and every line below this one describes
71
+ * the host as seen by THIS copy. An operator comparing two diagnoses has no other way to tell them
72
+ * apart.
73
+ */
74
+ function sourceCheck() {
75
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
76
+ const installed = root.split(/[\\/]/).includes('node_modules');
77
+ return check('source', 'ok', `${root} (${installed ? 'installed package' : 'clone'})`);
78
+ }
79
+
50
80
  /**
51
81
  * The runner asks git for the repository root before it picks a runs folder, so doctor has to
52
82
  * ask the same question: run from `src/runner`, a plain cwd would name the folder `runner` and
@@ -133,8 +163,23 @@ async function rulesCheck(host, record) {
133
163
  : check('rules', 'warn', `${record.rules.path} (modified after installation${ownerNote})`);
134
164
  }
135
165
 
166
+ async function permissionsCheck(host) {
167
+ try {
168
+ const status = await inspectPermissions(host.settingsPath);
169
+ // Permission rules are an optional operator action; Plan_22 keeps their absence a warning so
170
+ // doctor does not turn a healthy installation red merely because hardening was not requested.
171
+ // The ask count is part of the line because a full set shadowed by `ask` reads as working and
172
+ // is not: the live run of Plan_22-1 found this line saying `installed (24/24)` over it.
173
+ const shadow = status.askCount ? `, ${status.askCount} shadowed by ask` : '';
174
+ return check('permissions', status.state === 'installed' ? 'ok' : 'warn',
175
+ `${status.state} (${status.present}/${status.total} own strings in allow/deny${shadow})`);
176
+ } catch (err) {
177
+ return check('permissions', 'warn', `cannot inspect permission rules: ${err.message}`);
178
+ }
179
+ }
180
+
136
181
  export async function diagnose({ host, codexProbe = probeCodex, currentPackage } = {}) {
137
- const checks = [];
182
+ const checks = [sourceCheck()];
138
183
  const hostExists = await exists(host.root);
139
184
  checks.push(check('host', hostExists ? 'ok' : 'warn', `${host.root} (${host.scope}, ${hostExists ? 'exists' : 'absent'})`));
140
185
 
@@ -170,9 +215,12 @@ export async function diagnose({ host, codexProbe = probeCodex, currentPackage }
170
215
  ));
171
216
  const rules = await rulesCheck(host, record);
172
217
  checks.push(rules);
218
+ checks.push(await permissionsCheck(host));
173
219
  checks.push(...await hookChecks(host, record));
174
220
  const retention = retentionCheck(host);
175
221
  checks.push(retention);
222
+ const conventions = await conventionsCheck(host);
223
+ checks.push(conventions);
176
224
 
177
225
  const codex = codexProbe();
178
226
  checks.push(check('codex', codex.available ? 'ok' : 'warn', codex.value || 'available'));
@@ -184,7 +232,7 @@ export async function diagnose({ host, codexProbe = probeCodex, currentPackage }
184
232
 
185
233
  return {
186
234
  exitCode: !record || recordBroken || missingFiles.length || rules.status === 'fail'
187
- || retention.status === 'fail' || projectRuns.status === 'fail' ? 1 : 0,
235
+ || retention.status === 'fail' || conventions.status === 'fail' || projectRuns.status === 'fail' ? 1 : 0,
188
236
  checks,
189
237
  record,
190
238
  missingFiles,
@@ -194,6 +242,8 @@ export async function diagnose({ host, codexProbe = probeCodex, currentPackage }
194
242
  export function renderDoctor(result) {
195
243
  return result.checks.map(({ key, status, value }) => {
196
244
  const rendered = `[${status}] ${key}: ${value}`;
197
- return key === 'retention' && status === 'warn' ? `${WARNING}${rendered}${RESET}` : rendered;
245
+ return ['retention', 'conventions', 'permissions'].includes(key) && status === 'warn'
246
+ ? `${WARNING}${rendered}${RESET}`
247
+ : rendered;
198
248
  }).join('\n');
199
249
  }
package/cli/install.mjs CHANGED
@@ -14,7 +14,7 @@ import {
14
14
  writeInstallRecord,
15
15
  } from './manifest.mjs';
16
16
  import { copyPlannedFile, targetMatches } from './copy.mjs';
17
- import { commandFor, inspectHook, mergeHook } from './settings-merge.mjs';
17
+ import { commandFor, inspectHook, mergeHook, withSettingsRun } from './settings-merge.mjs';
18
18
  import { addRulesOwner, readRulesRegistry } from './rules-owners.mjs';
19
19
  import { readRunConfig, retentionNotice } from '../src/run-config.mjs';
20
20
 
@@ -58,7 +58,7 @@ function retentionOutput(line, output) {
58
58
  return `${output}\n${line}`;
59
59
  }
60
60
 
61
- export async function install({ host, dryRun = false, force = false, packageRoot } = {}) {
61
+ async function installInRun({ host, dryRun = false, force = false, packageRoot } = {}) {
62
62
  // Validate the shared registry before writes; package removal on a broken registry left the host without its watchdog.
63
63
  await readRulesRegistry(host);
64
64
  const configuredRetentionLine = retentionLine(host);
@@ -163,3 +163,9 @@ export async function install({ host, dryRun = false, force = false, packageRoot
163
163
  output: retentionOutput(configuredRetentionLine, `Installed ${plan.length + 1} files and registered the ${targets.map(({ definition }) => definition.event).join(' and ')} hooks.`),
164
164
  };
165
165
  }
166
+
167
+ export async function install(options = {}) {
168
+ const host = options?.host;
169
+ if (!host?.settingsPath) return installInRun(options);
170
+ return withSettingsRun(host.settingsPath, () => installInRun(options));
171
+ }
package/cli/manifest.mjs CHANGED
@@ -11,9 +11,23 @@ import {
11
11
  renderRequiredInputs,
12
12
  } from '../src/required-inputs.mjs';
13
13
  import { renderNoSelfExecution } from '../src/no-self-execution.mjs';
14
+ import { readJsonFile } from '../src/json-file.mjs';
14
15
 
15
16
  export { HOOK_DEFINITIONS };
16
17
 
18
+ /**
19
+ * Finds the definition a recorded hook belongs to. The event alone does not identify one: three of
20
+ * the four hooks are PreToolUse, so `find(d => d.event === hook.event)` returns the order gate for
21
+ * all three. `doctor` was fixed for exactly this and kept its own inline matching; `uninstall` was
22
+ * not, and its dry-run announced matcher `Agent|Task` for the worktree lock and the prune guard —
23
+ * the operator's only view of what a removal is about to touch. One function, so a third caller
24
+ * cannot repeat it.
25
+ */
26
+ export function definitionForRecordedHook(hook) {
27
+ return HOOK_DEFINITIONS.find((definition) => definition.event === hook?.event
28
+ && path.basename(String(hook?.path ?? '')) === definition.file);
29
+ }
30
+
17
31
  const HERE = path.dirname(fileURLToPath(import.meta.url));
18
32
  export const PACKAGE_ROOT = path.resolve(HERE, '..');
19
33
  export const INSTALL_RECORD_NAME = '.codex-bridge-install.json';
@@ -22,10 +36,11 @@ const RULES_SOURCE = `src/rules/${RULES_NAME}`;
22
36
  /**
23
37
  * Files the operator owns once they exist. Seeded on first install so the defaults are
24
38
  * visible and editable, then never written, never compared, never removed: run-config.json
25
- * is where a host states which model each mode runs on, and an installer that overwrites it
26
- * would erase that on the next update — the same way overwriting a .env would.
39
+ * is where a host states which model each mode runs on, conventions.md is the rule set it hands
40
+ * every run, and an installer that overwrites either would erase host decisions on the next
41
+ * update — the same way overwriting a .env would.
27
42
  */
28
- export const SEEDED_SOURCES = Object.freeze(['src/run-config.json']);
43
+ export const SEEDED_SOURCES = Object.freeze(['src/run-config.json', 'src/conventions.md']);
29
44
  const seededTargets = (host) =>
30
45
  new Set(SEEDED_SOURCES.map((source) => posix(
31
46
  path.relative(host.root, path.join(host.agentsDir, path.relative('src', source))),
@@ -48,7 +63,7 @@ export function replacePlaceholders(content, agentsDir) {
48
63
  }
49
64
 
50
65
  export async function packageInfo(packageRoot = PACKAGE_ROOT) {
51
- const parsed = JSON.parse(await fs.readFile(path.join(packageRoot, 'package.json'), 'utf8'));
66
+ const parsed = await readJsonFile(path.join(packageRoot, 'package.json'));
52
67
  return { name: parsed.name, version: parsed.version };
53
68
  }
54
69
 
@@ -190,19 +205,16 @@ export function normalizeInstallRecord(record) {
190
205
  }
191
206
 
192
207
  export async function readInstallRecord(host) {
193
- let raw;
208
+ let parsed;
194
209
  try {
195
- raw = await fs.readFile(installRecordPath(host), 'utf8');
210
+ parsed = await readJsonFile(installRecordPath(host));
196
211
  } catch (err) {
197
212
  if (err.code === 'ENOENT') return null;
213
+ if (err.cause) {
214
+ throw new Error(`invalid installation record JSON: ${err.cause.message}`, { cause: err.cause });
215
+ }
198
216
  throw err;
199
217
  }
200
- let parsed;
201
- try {
202
- parsed = JSON.parse(raw);
203
- } catch (err) {
204
- throw new Error(`invalid installation record JSON: ${err.message}`);
205
- }
206
218
  const record = normalizeInstallRecord(parsed);
207
219
  // Earlier versions installed the config as a package file and recorded it. Dropping it here
208
220
  // is what stops the next update from calling the operator's edited config an orphan and
@@ -0,0 +1,179 @@
1
+ /** Manages the optional shell permission rules for every codex-bridge command spelling. */
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { CLI_NAMES } from '../src/cli-names.mjs';
5
+ import { SHELL_TOOLS } from '../src/hook-definitions.mjs';
6
+ import { readSettings, updateSettings, withSettingsRun } from './settings-merge.mjs';
7
+
8
+ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
9
+ const entryPoint = fileURLToPath(new URL('../bin/codex-bridge.mjs', import.meta.url));
10
+ const nodeCommand = `node ${path.relative(packageRoot, entryPoint).split(path.sep).join('/')}`;
11
+ const commandNames = Object.freeze([...CLI_NAMES, nodeCommand]);
12
+ const formsFor = (command) => [command, `${command}:*`];
13
+
14
+ function buildRules(commandTransform) {
15
+ return Object.freeze(SHELL_TOOLS.flatMap((tool) => commandNames.flatMap((command) =>
16
+ formsFor(commandTransform(command)).map((form) => `${tool}(${form})`))));
17
+ }
18
+
19
+ /**
20
+ * The executable names stay owned by cli-names.mjs; only the clone entry point is derived here.
21
+ * Plan_22 records the incident because repeating the node spelling in CLI_NAMES would make the
22
+ * source list answer two different questions and let a future alias bypass one of them.
23
+ *
24
+ * The deny half subtracts `prune` from the allow half rather than guarding it: `Bash(codex-bridge:*)`
25
+ * would otherwise hand out the deletion this package refuses to give an agent. Its refusal is never
26
+ * seen in a live session — a PreToolUse hook answers before permissions are consulted, and
27
+ * prune-guard is installed always while these rules are optional. The 2026-08-09 run held that
28
+ * observation open as a checklist item until it turned out to be the answer, not the obstacle.
29
+ */
30
+ export const PERMISSION_RULES = Object.freeze({
31
+ allow: buildRules((command) => command),
32
+ deny: buildRules((command) => `${command} prune`),
33
+ });
34
+
35
+ const allRules = new Set([...PERMISSION_RULES.allow, ...PERMISSION_RULES.deny]);
36
+ const ruleLists = Object.freeze(['allow', 'deny', 'ask']);
37
+ const activeLists = Object.freeze(['allow', 'deny']);
38
+ const totalRules = PERMISSION_RULES.allow.length + PERMISSION_RULES.deny.length;
39
+
40
+ function listValue(settings, name) {
41
+ const list = settings?.permissions?.[name];
42
+ return Array.isArray(list) ? list : [];
43
+ }
44
+
45
+ function matchingRules(list, rules) {
46
+ return rules.filter((rule) => list.includes(rule)).length;
47
+ }
48
+
49
+ /**
50
+ * A count of strings in allow and deny is not the state of the set, because `ask` outranks `allow`:
51
+ * a copy sitting there keeps asking the question the rule was added to end. The live run of
52
+ * Plan_22-1 caught doctor reporting `installed (24/24)` over exactly that arrangement — the number
53
+ * was true and the conclusion drawn from it was false. Every consumer reads the state, so the
54
+ * shadow has to live in the state rather than in one command's wording.
55
+ */
56
+ function permissionStatus(settings) {
57
+ const counts = Object.fromEntries(activeLists.map((name) => [
58
+ name,
59
+ matchingRules(listValue(settings, name), PERMISSION_RULES[name]),
60
+ ]));
61
+ const askCount = matchingRules(listValue(settings, 'ask'), [...allRules]);
62
+ const present = counts.allow + counts.deny;
63
+ const complete = present === totalRules;
64
+ const state = askCount
65
+ ? (complete ? 'shadowed by ask' : 'partially installed, shadowed by ask')
66
+ : complete ? 'installed' : present === 0 ? 'absent' : 'partially installed';
67
+ return {
68
+ state,
69
+ complete,
70
+ counts,
71
+ askCount,
72
+ present,
73
+ total: totalRules,
74
+ };
75
+ }
76
+
77
+ function ensureList(settings, name) {
78
+ if (settings.permissions === undefined) settings.permissions = {};
79
+ if (!settings.permissions || typeof settings.permissions !== 'object'
80
+ || Array.isArray(settings.permissions)) {
81
+ throw new TypeError('settings.permissions must be an object');
82
+ }
83
+ if (settings.permissions[name] === undefined) settings.permissions[name] = [];
84
+ if (!Array.isArray(settings.permissions[name])) {
85
+ throw new TypeError(`settings.permissions.${name} must be an array`);
86
+ }
87
+ return settings.permissions[name];
88
+ }
89
+
90
+ function countRemovable(settings) {
91
+ return ruleLists.reduce((total, name) => total + listValue(settings, name)
92
+ .filter((entry) => allRules.has(entry)).length, 0);
93
+ }
94
+
95
+ export async function inspectPermissions(settingsPath) {
96
+ const state = await readSettings(settingsPath);
97
+ return { ...state, ...permissionStatus(state.settings) };
98
+ }
99
+
100
+ export async function addPermissionRules(settingsPath) {
101
+ return updateSettings(settingsPath, (settings) => {
102
+ let added = 0;
103
+ for (const name of activeLists) {
104
+ const list = ensureList(settings, name);
105
+ for (const rule of PERMISSION_RULES[name]) {
106
+ if (list.includes(rule)) continue;
107
+ list.push(rule);
108
+ added += 1;
109
+ }
110
+ }
111
+ return { changed: added > 0, added };
112
+ });
113
+ }
114
+
115
+ export async function removePermissionRules(settingsPath, { dryRun = false } = {}) {
116
+ if (dryRun) {
117
+ const state = await readSettings(settingsPath);
118
+ return { changed: false, removed: countRemovable(state.settings) };
119
+ }
120
+ return updateSettings(settingsPath, (settings) => {
121
+ let removed = 0;
122
+ for (const name of ruleLists) {
123
+ const list = settings?.permissions?.[name];
124
+ if (!Array.isArray(list)) continue;
125
+ settings.permissions[name] = list.filter((entry) => {
126
+ if (!allRules.has(entry)) return true;
127
+ removed += 1;
128
+ return false;
129
+ });
130
+ }
131
+ return { changed: removed > 0, removed };
132
+ });
133
+ }
134
+
135
+ function statusOutput(status) {
136
+ const moved = status.askCount
137
+ ? `; ${status.askCount} own string(s) in ask, which outranks allow`
138
+ : '';
139
+ return `Permissions: ${status.state} (${status.present}/${status.total} own strings in allow/deny${moved}).`;
140
+ }
141
+
142
+ async function permissionsInRun({ host, action } = {}) {
143
+ if (!host?.settingsPath) throw new TypeError('permissions requires a resolved host');
144
+ if (!action) {
145
+ const status = await inspectPermissions(host.settingsPath);
146
+ return { exitCode: 0, output: statusOutput(status), ...status };
147
+ }
148
+ if (action === 'add') {
149
+ const result = await addPermissionRules(host.settingsPath);
150
+ // A string the operator moved into `ask` outranks the copy `add` just put into `allow`, so the
151
+ // command would report a complete set while the questions it was run to end keep appearing.
152
+ // `ask` stays untouched — it is a hand-made decision — but silence about it is a false success.
153
+ const status = await inspectPermissions(host.settingsPath);
154
+ const conflict = status.askCount
155
+ ? ` ${status.askCount} own string(s) also sit in ask, which outranks allow; they were left there.`
156
+ : '';
157
+ return {
158
+ exitCode: 0,
159
+ output: `Added ${result.added} permission rule string${result.added === 1 ? '' : 's'}.${conflict}`,
160
+ ...result,
161
+ askCount: status.askCount,
162
+ };
163
+ }
164
+ if (action === 'remove') {
165
+ const result = await removePermissionRules(host.settingsPath);
166
+ return {
167
+ exitCode: 0,
168
+ output: `Removed ${result.removed} permission rule string${result.removed === 1 ? '' : 's'}.`,
169
+ ...result,
170
+ };
171
+ }
172
+ throw new Error(`unknown permissions action "${action}"`);
173
+ }
174
+
175
+ export async function permissions(options = {}) {
176
+ const host = options?.host;
177
+ if (!host?.settingsPath) return permissionsInRun(options);
178
+ return withSettingsRun(host.settingsPath, () => permissionsInRun(options));
179
+ }
package/cli/projects.mjs CHANGED
@@ -15,13 +15,26 @@ function formatSize(bytes) {
15
15
  return `${bytes} B`;
16
16
  }
17
17
 
18
- /** Both timestamp shapes in the store: an ISO stamp from artifacts, a folder name otherwise. */
18
+ const pad = (value) => String(value).padStart(2, '0');
19
+
20
+ /**
21
+ * Both timestamp shapes in the store, printed on one clock. A run folder is named in local time
22
+ * (`2026-08-07_144243`); `meta.finished_at` is an ISO stamp in UTC. Reading the ISO one with a
23
+ * regular expression printed its UTC hours in the same column as local ones, and the 2026-08-07
24
+ * live check (Plan_17-1 §1) saw the run whose folder says `144243` listed as `12:46` — the
25
+ * operator's own zone offset, looking exactly like a different run. Slicing a timestamp is cheaper
26
+ * than parsing it only until the value carries a zone. The folder name stays text because it is
27
+ * already local; parsing it would invent a zone it never had.
28
+ */
19
29
  function formatStamp(value) {
20
30
  if (typeof value !== 'string' || !value.trim()) return null;
21
- const iso = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})/.exec(value);
22
- if (iso) return `${iso[1]} ${iso[2]}`;
23
31
  const folder = /^(\d{4}-\d{2}-\d{2})_(\d{2})(\d{2})/.exec(value);
24
- return folder ? `${folder[1]} ${folder[2]}:${folder[3]}` : value;
32
+ if (folder) return `${folder[1]} ${folder[2]}:${folder[3]}`;
33
+ const moment = Date.parse(value);
34
+ if (Number.isNaN(moment)) return value;
35
+ const at = new Date(moment);
36
+ return `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())}`
37
+ + ` ${pad(at.getHours())}:${pad(at.getMinutes())}`;
25
38
  }
26
39
 
27
40
  const PROJECT_COLUMNS = [
@@ -112,7 +112,7 @@ export function parsePruneArgs(argv = []) {
112
112
  if (invalid) return invalid;
113
113
  }
114
114
 
115
- // The 30-day default belongs to the gentle sweep, which searches history for weight to shed.
115
+ // The 30-day default belongs to the gentle cleanup, which searches history for weight to shed.
116
116
  // A purge is never implicitly aged: `prune sbx2 --purge` means that folder, now. Defaulting it
117
117
  // made the plan's own fourth scenario unreachable — a young project answered "nothing to
118
118
  // remove", which reads as a broken command, not as a refusal.