@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.
- package/CHANGELOG.md +60 -0
- package/README.md +27 -7
- package/bin/codex-bridge.mjs +28 -4
- package/cli/doctor.mjs +53 -3
- package/cli/install.mjs +8 -2
- package/cli/manifest.mjs +24 -12
- package/cli/permissions.mjs +179 -0
- package/cli/projects.mjs +17 -4
- package/cli/prune-args.mjs +1 -1
- package/cli/rules-owners.mjs +6 -8
- package/cli/runs-inventory.mjs +2 -1
- package/cli/settings-merge.mjs +93 -45
- package/cli/stop.mjs +33 -20
- package/cli/uninstall.mjs +23 -10
- package/cli/unlock.mjs +239 -0
- package/package.json +3 -2
- package/scripts/check-file-size.mjs +8 -10
- package/src/agents/codex-build.md +2 -0
- package/src/agents/codex-review.md +2 -0
- package/src/agents/codex-scout.md +2 -0
- package/src/cli-names.mjs +6 -0
- package/src/conventions.md +44 -0
- package/src/heartbeat.mjs +1 -1
- package/src/hooks/live-runs.mjs +15 -14
- package/src/hooks/order-gate.mjs +7 -2
- package/src/hooks/prune-guard.mjs +12 -4
- package/src/hooks/reply-guard.mjs +4 -3
- package/src/json-file.mjs +30 -0
- package/src/meta/chain.mjs +8 -6
- package/src/meta/paths.mjs +5 -9
- package/src/meta/pre-start.mjs +30 -0
- package/src/meta/run-state.mjs +31 -16
- package/src/process-identity.mjs +140 -0
- package/src/required-inputs.mjs +42 -0
- package/src/run-config.mjs +4 -8
- package/src/runner/args.mjs +16 -2
- package/src/runner/attach.mjs +23 -17
- package/src/runner/codex-cmd.mjs +1 -1
- package/src/runner/conventions.mjs +39 -0
- package/src/runner/launcher.mjs +43 -16
- package/src/runner/project-dir.mjs +4 -3
- package/src/runner/scope-check.mjs +159 -0
- package/src/runner/worker.mjs +13 -5
- package/src/write-meta.mjs +8 -1
- 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
|
|
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
|
-
`
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
package/bin/codex-bridge.mjs
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
26
|
-
*
|
|
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 =
|
|
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
|
|
208
|
+
let parsed;
|
|
194
209
|
try {
|
|
195
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = [
|
package/cli/prune-args.mjs
CHANGED
|
@@ -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
|
|
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.
|