@lyupro/codex-bridge 0.5.4 → 0.6.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 +87 -0
- package/README.md +4 -1
- package/bin/codex-bridge.mjs +18 -10
- package/cli/codex-user-config.mjs +36 -0
- package/cli/doctor-installation.mjs +2 -2
- package/cli/install-record.mjs +15 -2
- package/cli/install.mjs +54 -7
- package/cli/manifest.mjs +1 -1
- package/cli/model-catalogue.mjs +81 -0
- package/cli/model-set.mjs +141 -0
- package/cli/model-speed.mjs +96 -0
- package/cli/model.mjs +84 -0
- package/cli/permissions.mjs +32 -14
- package/cli/probe-contract.mjs +7 -1
- package/cli/prune-plan.mjs +4 -4
- package/cli/prune.mjs +8 -8
- package/cli/rules-owners.mjs +3 -70
- package/cli/table.mjs +6 -6
- package/package.json +2 -2
- package/src/agents/codex-review.md +4 -4
- package/src/commands/model.md +55 -0
- package/src/home/hooks/order-gate.mjs +2 -1
- package/src/home/hooks/reply-guard.mjs +2 -1
- package/src/home/hooks/shell-write-intent.mjs +36 -2
- package/src/home/lib/agents.mjs +15 -0
- package/src/home/lib/config-edit.mjs +159 -0
- package/src/home/lib/config-validate.mjs +227 -0
- package/src/home/lib/file-lock.mjs +76 -0
- package/src/home/lib/meta/reply.mjs +7 -6
- package/src/home/lib/run-codex.mjs +1 -1
- package/src/home/lib/run-config.mjs +15 -247
- package/src/home/lib/runner/args.mjs +8 -10
- package/src/home/lib/runner/codex-args.mjs +16 -19
- package/src/home/lib/runner/git-state.mjs +5 -5
- package/src/home/lib/runner/launcher.mjs +4 -3
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/** Plan_56 step 4: paid tiers are catalogue decisions applied only by an explicit second call. */
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { CONFIG_PATH } from '../src/home/lib/run-config.mjs';
|
|
4
|
+
import { ROLES, validateRunConfig } from '../src/home/lib/config-validate.mjs';
|
|
5
|
+
import { editRunConfig } from '../src/home/lib/config-edit.mjs';
|
|
6
|
+
import { fetchCatalogue, parseCatalogue } from './model-catalogue.mjs';
|
|
7
|
+
import { describe, readProfiles } from './model-set.mjs';
|
|
8
|
+
|
|
9
|
+
const failure = (exitCode, message) => ({ exitCode, output: `codex-bridge model: ${message}` });
|
|
10
|
+
const refusal = (message) => Object.assign(new Error(message), { exitCode: 2 });
|
|
11
|
+
|
|
12
|
+
export async function editModelSpeed(argv, options) {
|
|
13
|
+
const [role, speed, confirmation, ...extra] = argv;
|
|
14
|
+
if (!ROLES.includes(role)) {
|
|
15
|
+
return failure(2, `${role ? `unknown role "${role}"` : 'role is required'}. Allowed roles: ${ROLES.join(', ')}.`);
|
|
16
|
+
}
|
|
17
|
+
if (typeof speed !== 'string' || !speed || /\s/.test(speed)) {
|
|
18
|
+
return failure(2, 'speed requires a tier identifier: a non-empty single word with no whitespace, or unset.');
|
|
19
|
+
}
|
|
20
|
+
if (confirmation !== undefined && (speed === 'unset' || confirmation !== 'confirm')) {
|
|
21
|
+
return failure(2, `unexpected argument "${confirmation}".`);
|
|
22
|
+
}
|
|
23
|
+
if (extra.length) return failure(2, `unexpected argument "${extra[0]}".`);
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const configPath = path.resolve(options.configPath ?? CONFIG_PATH);
|
|
27
|
+
let catalogue;
|
|
28
|
+
let tier;
|
|
29
|
+
if (speed !== 'unset') {
|
|
30
|
+
if (!readProfiles(configPath)[role]?.model) {
|
|
31
|
+
return failure(2, `${role} has no configured model; run model set ${role} <model> first. Nothing written.`);
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
catalogue = parseCatalogue(await (options.fetchCatalogue ?? fetchCatalogue)());
|
|
35
|
+
} catch (error) {
|
|
36
|
+
return failure(1, `live catalogue unavailable; refusing to set ${role} speed: ${error?.message ?? String(error)}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let change;
|
|
41
|
+
let catalogueModel;
|
|
42
|
+
const transform = (current) => {
|
|
43
|
+
// Same absent-key rule as model-set: a missing config still answers about the role.
|
|
44
|
+
if (current !== undefined) validateRunConfig(configPath, { models: current });
|
|
45
|
+
const profiles = { ...current };
|
|
46
|
+
if (speed !== 'unset') {
|
|
47
|
+
const modelId = profiles[role]?.model?.trim();
|
|
48
|
+
// D40/D41: reusing a tier decision after a role changes model would validate the wrong pair.
|
|
49
|
+
if (catalogueModel !== undefined && modelId !== catalogueModel) {
|
|
50
|
+
throw new Error('The profile changed while the catalogue was being read; nothing was written. Run the command again.');
|
|
51
|
+
}
|
|
52
|
+
if (!modelId) {
|
|
53
|
+
throw refusal(`${role} has no configured model; run model set ${role} <model> first. Nothing written.`);
|
|
54
|
+
}
|
|
55
|
+
const entry = catalogue.find(({ slug }) => slug === modelId);
|
|
56
|
+
if (!entry) {
|
|
57
|
+
throw refusal(`unknown model "${modelId}". Available models: ${catalogue.map(({ slug }) => slug).join(', ') || '(empty catalogue)'}. Nothing written.`);
|
|
58
|
+
}
|
|
59
|
+
if (!entry.serviceTiers.length) {
|
|
60
|
+
throw refusal(`model "${modelId}" has no accelerated tier. Nothing written.`);
|
|
61
|
+
}
|
|
62
|
+
tier = entry.serviceTiers.find(({ id }) => id === speed);
|
|
63
|
+
if (!tier) {
|
|
64
|
+
throw refusal(`speed "${speed}" is not supported by model "${modelId}". `
|
|
65
|
+
+ `Accelerated tiers: ${entry.serviceTiers.map(({ id }) => id).join(', ')}. Nothing written.`);
|
|
66
|
+
}
|
|
67
|
+
catalogueModel = entry.slug;
|
|
68
|
+
}
|
|
69
|
+
const before = profiles[role];
|
|
70
|
+
const after = { ...before };
|
|
71
|
+
if (speed === 'unset') delete after.speed;
|
|
72
|
+
else after.speed = speed;
|
|
73
|
+
change = `${role}: ${describe(before)} -> ${describe(after)}`;
|
|
74
|
+
if (before || speed !== 'unset') profiles[role] = after;
|
|
75
|
+
return profiles;
|
|
76
|
+
};
|
|
77
|
+
if (speed !== 'unset' && confirmation !== 'confirm') {
|
|
78
|
+
transform(readProfiles(configPath));
|
|
79
|
+
return {
|
|
80
|
+
exitCode: 0,
|
|
81
|
+
output: `Preview — ${change}\nCatalogue description: "${tier.description}"\n`
|
|
82
|
+
+ 'Credit multipliers: https://learn.chatgpt.com/docs/agent-configuration/speed\n'
|
|
83
|
+
+ `Run a second call ending in confirm to apply: codex-bridge model speed ${role} ${speed} confirm\nNothing written.`,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
await editRunConfig('models', transform, configPath);
|
|
87
|
+
return {
|
|
88
|
+
exitCode: 0,
|
|
89
|
+
output: `${change}\nConfig file: ${configPath}\n`
|
|
90
|
+
+ 'Machine-wide: shared by every project on this machine, not per-project.',
|
|
91
|
+
};
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (error.exitCode === 2) return failure(2, error.message);
|
|
94
|
+
return failure(1, `cannot ${speed === 'unset' ? 'unset' : 'set'} speed: ${error?.message ?? String(error)}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
package/cli/model.mjs
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/** Shows and edits machine-wide delegated profiles using the live catalogue (Plan_56). */
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { AGENTS } from '../src/home/lib/agents.mjs';
|
|
4
|
+
import { CONFIG_PATH, readRunConfig } from '../src/home/lib/run-config.mjs';
|
|
5
|
+
import { runProfile } from '../src/home/lib/runner/codex-args.mjs';
|
|
6
|
+
import { fetchCatalogue, parseCatalogue } from './model-catalogue.mjs';
|
|
7
|
+
import { editModelProfile } from './model-set.mjs';
|
|
8
|
+
import { editModelSpeed } from './model-speed.mjs';
|
|
9
|
+
import { readCodexUserTier } from './codex-user-config.mjs';
|
|
10
|
+
import { renderTable } from './table.mjs';
|
|
11
|
+
|
|
12
|
+
const PROFILE_COLUMNS = ['role', 'model', 'effort', 'speed', 'source']
|
|
13
|
+
.map((key) => ({ key, header: key, fixed: true }));
|
|
14
|
+
const CATALOGUE_COLUMNS = ['slug', 'reasoning levels', 'default level', 'fast tier', 'visibility']
|
|
15
|
+
.map((key) => ({ key, header: key, fixed: true }));
|
|
16
|
+
|
|
17
|
+
// An unpinned model is not a default this package supplies — Codex picks one, and saying
|
|
18
|
+
// "built-in default" would credit the package with a choice it never made. The three cases stay
|
|
19
|
+
// three, because provenance is half of what this command exists to answer.
|
|
20
|
+
const MODEL_SOURCE = { config: 'config file' };
|
|
21
|
+
const EFFORT_SOURCE = { config: 'config file', request: 'this run' };
|
|
22
|
+
|
|
23
|
+
function profileSource(profile, role, userTier) {
|
|
24
|
+
const model = MODEL_SOURCE[profile.model_source] ?? 'not set (Codex chooses)';
|
|
25
|
+
const effort = EFFORT_SOURCE[profile.effort_source] ?? 'package default';
|
|
26
|
+
const source = model === effort ? model : `model: ${model}; effort: ${effort}`;
|
|
27
|
+
const speed = profile.speed_source === 'config' ? 'config file'
|
|
28
|
+
: role === 'build' && userTier ? `operator Codex config (${userTier})` : 'not pinned';
|
|
29
|
+
const ignored = role === 'build' ? '' : ' (operator Codex config not read)';
|
|
30
|
+
return `${source}; speed: ${speed}${ignored}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Returns output for the dispatcher; explicit edit actions use the shared config writer. */
|
|
34
|
+
export async function model(argv = [], options = {}) {
|
|
35
|
+
let action;
|
|
36
|
+
let optionArgs = argv;
|
|
37
|
+
if (argv[0] && !argv[0].startsWith('-')) {
|
|
38
|
+
action = argv[0];
|
|
39
|
+
optionArgs = argv.slice(1);
|
|
40
|
+
}
|
|
41
|
+
if (action === 'set' || action === 'unset') {
|
|
42
|
+
return editModelProfile(action, optionArgs, options);
|
|
43
|
+
}
|
|
44
|
+
if (action === 'speed') return editModelSpeed(optionArgs, options);
|
|
45
|
+
if (action && action !== 'list') {
|
|
46
|
+
return { exitCode: 2, output: `codex-bridge model: unknown action "${action}". Use model, model list, model set, model unset or model speed.` };
|
|
47
|
+
}
|
|
48
|
+
if (optionArgs.length) {
|
|
49
|
+
return { exitCode: 2, output: `codex-bridge model: unexpected argument "${optionArgs[0]}".` };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
if (action === 'list') {
|
|
54
|
+
const rows = parseCatalogue(await (options.fetchCatalogue ?? fetchCatalogue)());
|
|
55
|
+
return {
|
|
56
|
+
exitCode: 0,
|
|
57
|
+
output: renderTable(CATALOGUE_COLUMNS, rows, options.terminalWidth)
|
|
58
|
+
+ (rows.length ? '' : '\nNo models returned by the live catalogue.'),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const configPath = path.resolve(options.configPath ?? CONFIG_PATH);
|
|
63
|
+
const config = readRunConfig(configPath);
|
|
64
|
+
const userTier = readCodexUserTier(options);
|
|
65
|
+
const rows = Object.entries(AGENTS).map(([agent, { role }]) => {
|
|
66
|
+
const profile = runProfile({ agent, models: config.models });
|
|
67
|
+
return {
|
|
68
|
+
role,
|
|
69
|
+
model: profile.model || 'Codex default (not pinned)',
|
|
70
|
+
effort: profile.effort,
|
|
71
|
+
speed: profile.speed || 'not pinned',
|
|
72
|
+
source: profileSource(profile, role, userTier),
|
|
73
|
+
};
|
|
74
|
+
});
|
|
75
|
+
return {
|
|
76
|
+
exitCode: 0,
|
|
77
|
+
output: `${renderTable(PROFILE_COLUMNS, rows, options.terminalWidth)}\n\nConfig file: ${configPath}\n`
|
|
78
|
+
+ 'Machine-wide: shared by every project on this machine, not per-project.',
|
|
79
|
+
};
|
|
80
|
+
} catch (error) {
|
|
81
|
+
const subject = action === 'list' ? 'live catalogue unavailable; refusing to list models' : 'cannot read profile';
|
|
82
|
+
return { exitCode: 1, output: `codex-bridge model: ${subject}: ${error?.message ?? String(error)}` };
|
|
83
|
+
}
|
|
84
|
+
}
|
package/cli/permissions.mjs
CHANGED
|
@@ -22,20 +22,31 @@ function buildRules(commandTransform) {
|
|
|
22
22
|
* source list answer two different questions and let a future alias bypass one of them.
|
|
23
23
|
*
|
|
24
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.
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* observation open as a checklist item until it turned out to be the answer, not the obstacle.
|
|
25
|
+
* would otherwise hand out the deletion this package refuses to give an agent. Plan_56's
|
|
26
|
+
* 2026-09-07 reconnaissance established deny, ask, allow ordering before PreToolUse hooks:
|
|
27
|
+
* the narrow speed ask therefore wins over the broad allow, and no hook can waive it.
|
|
29
28
|
*/
|
|
30
29
|
export const PERMISSION_RULES = Object.freeze({
|
|
31
30
|
allow: buildRules((command) => command),
|
|
32
31
|
deny: buildRules((command) => `${command} prune`),
|
|
32
|
+
ask: buildRules((command) => `${command} model speed`),
|
|
33
33
|
});
|
|
34
34
|
|
|
35
|
-
const allRules = new Set(
|
|
35
|
+
const allRules = new Set(Object.values(PERMISSION_RULES).flat());
|
|
36
36
|
const ruleLists = Object.freeze(['allow', 'deny', 'ask']);
|
|
37
|
-
const activeLists =
|
|
38
|
-
const totalRules =
|
|
37
|
+
const activeLists = ruleLists;
|
|
38
|
+
const totalRules = allRules.size;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Which lists a count covers, spelled once.
|
|
42
|
+
*
|
|
43
|
+
* Two sentences report that count — this module's own line and doctor's — and both used to name
|
|
44
|
+
* the lists in prose. Adding `ask` in Plan_56 updated one and left the other saying `allow/deny`
|
|
45
|
+
* over a number that already included the third list: a true number under a false label, which is
|
|
46
|
+
* the same defect Plan_22-1 caught in this very line. Derived from the list of lists so a fourth
|
|
47
|
+
* one cannot introduce a third spelling.
|
|
48
|
+
*/
|
|
49
|
+
export const RULE_LIST_NAMES = ruleLists.join('/');
|
|
39
50
|
|
|
40
51
|
function listValue(settings, name) {
|
|
41
52
|
const list = settings?.permissions?.[name];
|
|
@@ -47,19 +58,20 @@ function matchingRules(list, rules) {
|
|
|
47
58
|
}
|
|
48
59
|
|
|
49
60
|
/**
|
|
50
|
-
* A count of strings
|
|
61
|
+
* A count of installed strings is not the state of the set, because `ask` outranks `allow`:
|
|
51
62
|
* a copy sitting there keeps asking the question the rule was added to end. The live run of
|
|
52
63
|
* Plan_22-1 caught doctor reporting `installed (24/24)` over exactly that arrangement — the number
|
|
53
64
|
* 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.
|
|
65
|
+
* shadow has to live in the state rather than in one command's wording. Plan_56's own speed ask
|
|
66
|
+
* rules are intentional; only allow strings copied into ask count as that shadow.
|
|
55
67
|
*/
|
|
56
68
|
function permissionStatus(settings) {
|
|
57
69
|
const counts = Object.fromEntries(activeLists.map((name) => [
|
|
58
70
|
name,
|
|
59
71
|
matchingRules(listValue(settings, name), PERMISSION_RULES[name]),
|
|
60
72
|
]));
|
|
61
|
-
const askCount = matchingRules(listValue(settings, 'ask'),
|
|
62
|
-
const present = counts.allow + counts.deny;
|
|
73
|
+
const askCount = matchingRules(listValue(settings, 'ask'), PERMISSION_RULES.allow);
|
|
74
|
+
const present = counts.allow + counts.deny + counts.ask;
|
|
63
75
|
const complete = present === totalRules;
|
|
64
76
|
const state = askCount
|
|
65
77
|
? (complete ? 'shadowed by ask' : 'partially installed, shadowed by ask')
|
|
@@ -108,7 +120,13 @@ export async function addPermissionRules(settingsPath) {
|
|
|
108
120
|
added += 1;
|
|
109
121
|
}
|
|
110
122
|
}
|
|
111
|
-
|
|
123
|
+
const status = permissionStatus(settings);
|
|
124
|
+
return {
|
|
125
|
+
changed: added > 0,
|
|
126
|
+
added,
|
|
127
|
+
present: status.present,
|
|
128
|
+
total: status.total,
|
|
129
|
+
};
|
|
112
130
|
});
|
|
113
131
|
}
|
|
114
132
|
|
|
@@ -136,7 +154,7 @@ function statusOutput(status) {
|
|
|
136
154
|
const moved = status.askCount
|
|
137
155
|
? `; ${status.askCount} own string(s) in ask, which outranks allow`
|
|
138
156
|
: '';
|
|
139
|
-
return `Permissions: ${status.state} (${status.present}/${status.total} own strings in
|
|
157
|
+
return `Permissions: ${status.state} (${status.present}/${status.total} own strings in ${RULE_LIST_NAMES}${moved}).`;
|
|
140
158
|
}
|
|
141
159
|
|
|
142
160
|
async function permissionsInRun({ host, action } = {}) {
|
|
@@ -149,7 +167,7 @@ async function permissionsInRun({ host, action } = {}) {
|
|
|
149
167
|
const result = await addPermissionRules(host.settingsPath);
|
|
150
168
|
// A string the operator moved into `ask` outranks the copy `add` just put into `allow`, so the
|
|
151
169
|
// command would report a complete set while the questions it was run to end keep appearing.
|
|
152
|
-
// `ask`
|
|
170
|
+
// That allow string stays in `ask` — it is a hand-made decision — alongside our speed rules.
|
|
153
171
|
const status = await inspectPermissions(host.settingsPath);
|
|
154
172
|
const conflict = status.askCount
|
|
155
173
|
? ` ${status.askCount} own string(s) also sit in ask, which outranks allow; they were left there.`
|
package/cli/probe-contract.mjs
CHANGED
|
@@ -23,6 +23,10 @@ const exists = async (target) => {
|
|
|
23
23
|
}
|
|
24
24
|
};
|
|
25
25
|
|
|
26
|
+
function lastNonEmptyLine(output) {
|
|
27
|
+
return String(output ?? '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean).at(-1);
|
|
28
|
+
}
|
|
29
|
+
|
|
26
30
|
export function judgeProbe({ markerExists, hookFired, hostResult }) {
|
|
27
31
|
if (markerExists) {
|
|
28
32
|
return { result: 'ignored', reason: 'The marker command ran despite the hook refusal.' };
|
|
@@ -41,7 +45,9 @@ export function judgeProbe({ markerExists, hookFired, hostResult }) {
|
|
|
41
45
|
return { result: null, reason: `The host was terminated by signal ${hostResult.signal}.` };
|
|
42
46
|
}
|
|
43
47
|
if (hostResult.status !== 0) {
|
|
44
|
-
|
|
48
|
+
const output = lastNonEmptyLine(hostResult.stderr) || lastNonEmptyLine(hostResult.stdout);
|
|
49
|
+
const detail = output ? `: ${output.slice(0, 200)}` : '';
|
|
50
|
+
return { result: null, reason: `The host exited with status ${String(hostResult.status)}${detail}.` };
|
|
45
51
|
}
|
|
46
52
|
if (!hookFired) {
|
|
47
53
|
return { result: null, reason: 'The probe hook never received the marker command.' };
|
package/cli/prune-plan.mjs
CHANGED
|
@@ -51,7 +51,7 @@ function gentleAction(root, project, run) {
|
|
|
51
51
|
if (!files.length) return null;
|
|
52
52
|
return {
|
|
53
53
|
kind: 'run',
|
|
54
|
-
|
|
54
|
+
strategy: 'gentle',
|
|
55
55
|
project,
|
|
56
56
|
run: run.run,
|
|
57
57
|
path: runDir,
|
|
@@ -65,7 +65,7 @@ function runPurgeAction(root, project, run) {
|
|
|
65
65
|
const runDir = path.join(root, project, run.run);
|
|
66
66
|
return {
|
|
67
67
|
kind: 'run',
|
|
68
|
-
|
|
68
|
+
strategy: 'purge',
|
|
69
69
|
project,
|
|
70
70
|
run: run.run,
|
|
71
71
|
path: runDir,
|
|
@@ -79,7 +79,7 @@ function projectPurgeAction(root, project, runs) {
|
|
|
79
79
|
const projectDir = path.join(root, project);
|
|
80
80
|
return {
|
|
81
81
|
kind: 'project',
|
|
82
|
-
|
|
82
|
+
strategy: 'purge',
|
|
83
83
|
project,
|
|
84
84
|
run: null,
|
|
85
85
|
path: projectDir,
|
|
@@ -194,7 +194,7 @@ export function prunePlan(args = {}, options = {}) {
|
|
|
194
194
|
return {
|
|
195
195
|
root,
|
|
196
196
|
scope,
|
|
197
|
-
|
|
197
|
+
strategy: args.purge ? 'purge' : 'gentle',
|
|
198
198
|
project: args.projectName || null,
|
|
199
199
|
run: args.runName || null,
|
|
200
200
|
olderThan: age,
|
package/cli/prune.mjs
CHANGED
|
@@ -34,7 +34,7 @@ function commandText(args) {
|
|
|
34
34
|
function actionPayload(action) {
|
|
35
35
|
return {
|
|
36
36
|
kind: action.kind,
|
|
37
|
-
|
|
37
|
+
strategy: action.strategy,
|
|
38
38
|
project: action.project,
|
|
39
39
|
run: action.run,
|
|
40
40
|
targets: action.targets,
|
|
@@ -45,7 +45,7 @@ function actionPayload(action) {
|
|
|
45
45
|
function basePayload(plan) {
|
|
46
46
|
return {
|
|
47
47
|
scope: plan.scope,
|
|
48
|
-
|
|
48
|
+
strategy: plan.strategy,
|
|
49
49
|
project: plan.project,
|
|
50
50
|
run: plan.run,
|
|
51
51
|
olderThan: ageText(plan.olderThan),
|
|
@@ -57,7 +57,7 @@ function basePayload(plan) {
|
|
|
57
57
|
|
|
58
58
|
function planLines(plan, args) {
|
|
59
59
|
const lines = [
|
|
60
|
-
`Prune plan: ${plan.
|
|
60
|
+
`Prune plan: ${plan.strategy} ${plan.scope}.`,
|
|
61
61
|
plan.olderThan ? `Age filter: older than ${ageText(plan.olderThan)} (folder name date).` : 'Age filter: none.',
|
|
62
62
|
];
|
|
63
63
|
for (const action of plan.actions) {
|
|
@@ -160,8 +160,8 @@ function linkedSegment(target, root) {
|
|
|
160
160
|
return null;
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
-
function currentTargetBytes(target,
|
|
164
|
-
if (
|
|
163
|
+
function currentTargetBytes(target, strategy) {
|
|
164
|
+
if (strategy === 'purge') {
|
|
165
165
|
// The inventory's recursive measurement is the same accounting used by projects and keeps
|
|
166
166
|
// the report honest when a purge contains nested files.
|
|
167
167
|
return recursiveSize(target);
|
|
@@ -176,7 +176,7 @@ function currentTargetBytes(target, mode) {
|
|
|
176
176
|
async function confirmAll(plan, args, options) {
|
|
177
177
|
const prompt = options.prompt || defaultPrompt;
|
|
178
178
|
for (const action of plan.actions) {
|
|
179
|
-
const question = action.
|
|
179
|
+
const question = action.strategy === 'purge'
|
|
180
180
|
? `Delete folder ${action.path}?`
|
|
181
181
|
: `Delete archived transport from ${action.project}/${action.run}?\n ${action.targets.join('\n ')}`;
|
|
182
182
|
let answer;
|
|
@@ -211,9 +211,9 @@ async function execute(plan, options) {
|
|
|
211
211
|
failed.push({ target, error: `refusing to delete through a link: ${linked}` });
|
|
212
212
|
continue;
|
|
213
213
|
}
|
|
214
|
-
const bytes = currentTargetBytes(target, action.
|
|
214
|
+
const bytes = currentTargetBytes(target, action.strategy);
|
|
215
215
|
try {
|
|
216
|
-
fs.rmSync(target, { recursive: action.
|
|
216
|
+
fs.rmSync(target, { recursive: action.strategy === 'purge', force: false });
|
|
217
217
|
removed.push(target);
|
|
218
218
|
sizes.push(bytes);
|
|
219
219
|
} catch (err) {
|
package/cli/rules-owners.mjs
CHANGED
|
@@ -4,14 +4,12 @@ import { randomUUID } from 'node:crypto';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { normalizeRepoPath } from '../src/home/lib/runner/project-dir.mjs';
|
|
6
6
|
import { readJsonFile } from '../src/home/lib/json-file.mjs';
|
|
7
|
+
import { withFileLock } from '../src/home/lib/file-lock.mjs';
|
|
8
|
+
export { isLockTaken } from '../src/home/lib/file-lock.mjs';
|
|
7
9
|
|
|
8
10
|
export const RULES_REGISTRY_NAME = '.codex-bridge-rules.json';
|
|
9
11
|
export const RULES_REGISTRY_VERSION = 1;
|
|
10
12
|
|
|
11
|
-
const REGISTRY_LOCK_RETRIES = 200;
|
|
12
|
-
const REGISTRY_LOCK_DELAY_MS = 5;
|
|
13
|
-
const REGISTRY_LOCK_STALE_MS = 30_000;
|
|
14
|
-
|
|
15
13
|
export function rulesRegistryPath(host) {
|
|
16
14
|
return path.join(host.codexRulesDir, RULES_REGISTRY_NAME);
|
|
17
15
|
}
|
|
@@ -77,73 +75,8 @@ function registryLockPath(host) {
|
|
|
77
75
|
return `${rulesRegistryPath(host)}.lock`;
|
|
78
76
|
}
|
|
79
77
|
|
|
80
|
-
function waitForRegistryLock() {
|
|
81
|
-
return new Promise((resolve) => setTimeout(resolve, REGISTRY_LOCK_DELAY_MS));
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* A lock nobody released is worse than no lock: interrupting an install left the file behind and
|
|
86
|
-
* every later install and uninstall died on it until someone deleted it by hand (reproduced
|
|
87
|
-
* 2026-08-04). An owner update takes milliseconds, so a lock older than the staleness window
|
|
88
|
-
* belongs to a process that is gone.
|
|
89
|
-
*/
|
|
90
|
-
async function dropStaleLock(lockPath) {
|
|
91
|
-
try {
|
|
92
|
-
const { mtimeMs } = await fs.stat(lockPath);
|
|
93
|
-
if (Date.now() - mtimeMs < REGISTRY_LOCK_STALE_MS) return false;
|
|
94
|
-
await fs.rm(lockPath, { force: true });
|
|
95
|
-
return true;
|
|
96
|
-
} catch {
|
|
97
|
-
// Vanished or unreadable — let the next open() decide rather than guess here.
|
|
98
|
-
return false;
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* Windows answers a taken lock with three different codes, and only one of them is EEXIST. A file
|
|
104
|
-
* whose last handle closed while a delete was pending stays visible but refuses to be opened:
|
|
105
|
-
* `open(..., 'wx')` comes back EPERM, and a file another process still holds comes back EBUSY.
|
|
106
|
-
* Treating those as fatal is what made `tests/cli/rules-owners.test.mjs` fail twice on 2026-08-11
|
|
107
|
-
* with EPERM on the lock file — the suite went red over a lock that was simply busy for one more
|
|
108
|
-
* millisecond. All three mean the same thing to a caller waiting for a lock: not yours yet.
|
|
109
|
-
*/
|
|
110
|
-
const LOCK_TAKEN_CODES = new Set(['EEXIST', 'EPERM', 'EBUSY']);
|
|
111
|
-
|
|
112
|
-
/** Exported so the retry contract is asserted directly; the race itself reproduces only by luck. */
|
|
113
|
-
export function isLockTaken(err) {
|
|
114
|
-
return LOCK_TAKEN_CODES.has(err?.code);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
async function acquireRegistryLock(host) {
|
|
118
|
-
await fs.mkdir(host.codexRulesDir, { recursive: true });
|
|
119
|
-
const lockPath = registryLockPath(host);
|
|
120
|
-
let lastTaken;
|
|
121
|
-
for (let attempt = 0; attempt < REGISTRY_LOCK_RETRIES; attempt += 1) {
|
|
122
|
-
try {
|
|
123
|
-
const handle = await fs.open(lockPath, 'wx');
|
|
124
|
-
return { handle, lockPath };
|
|
125
|
-
} catch (err) {
|
|
126
|
-
if (!isLockTaken(err)) throw err;
|
|
127
|
-
lastTaken = err;
|
|
128
|
-
if (!(await dropStaleLock(lockPath))) await waitForRegistryLock();
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
// The code is part of the message: a timeout on EPERM points at a delete that never completed,
|
|
132
|
-
// a timeout on EEXIST at an owner that never released.
|
|
133
|
-
throw new Error(
|
|
134
|
-
`timed out waiting for rules ownership registry lock: ${lockPath} (last attempt: ${lastTaken.code})`,
|
|
135
|
-
{ cause: lastTaken },
|
|
136
|
-
);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
78
|
async function withRegistryLock(host, action) {
|
|
140
|
-
|
|
141
|
-
try {
|
|
142
|
-
return await action();
|
|
143
|
-
} finally {
|
|
144
|
-
await lock.handle.close().catch(() => {});
|
|
145
|
-
await fs.rm(lock.lockPath, { force: true }).catch(() => {});
|
|
146
|
-
}
|
|
79
|
+
return withFileLock(registryLockPath(host), action, { description: 'rules ownership registry' });
|
|
147
80
|
}
|
|
148
81
|
|
|
149
82
|
export async function readRulesRegistry(host) {
|
package/cli/table.mjs
CHANGED
|
@@ -23,10 +23,10 @@ export function truncateStart(value, width) {
|
|
|
23
23
|
return `…${Array.from(text).slice(-(width - 1)).join('')}`;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
function fit(value, width,
|
|
26
|
+
function fit(value, width, truncate) {
|
|
27
27
|
if (displayWidth(value) <= width) return value;
|
|
28
|
-
if (
|
|
29
|
-
if (
|
|
28
|
+
if (truncate === 'start') return truncateStart(value, width);
|
|
29
|
+
if (truncate === 'end') return `${Array.from(value).slice(0, Math.max(0, width - 1)).join('')}…`;
|
|
30
30
|
return value;
|
|
31
31
|
}
|
|
32
32
|
|
|
@@ -39,7 +39,7 @@ function normalizeColumn(column) {
|
|
|
39
39
|
header: String(column.header ?? column.label ?? column.key),
|
|
40
40
|
kind,
|
|
41
41
|
align: column.align || (kind === 'number' ? 'right' : 'left'),
|
|
42
|
-
|
|
42
|
+
truncate: column.truncate === undefined ? (fixed ? null : 'start') : column.truncate,
|
|
43
43
|
};
|
|
44
44
|
}
|
|
45
45
|
|
|
@@ -65,7 +65,7 @@ function columnWidths(columns, rows, width) {
|
|
|
65
65
|
|
|
66
66
|
const flexible = columns
|
|
67
67
|
.map((column, index) => ({ column, index }))
|
|
68
|
-
.filter(({ column }) => column.
|
|
68
|
+
.filter(({ column }) => column.truncate === 'start' || column.truncate === 'end');
|
|
69
69
|
while (spare < 0) {
|
|
70
70
|
let changed = false;
|
|
71
71
|
for (const { column, index } of flexible) {
|
|
@@ -84,7 +84,7 @@ function columnWidths(columns, rows, width) {
|
|
|
84
84
|
function renderRow(row, columns, widths, header = false) {
|
|
85
85
|
return columns.map((column, index) => {
|
|
86
86
|
const value = header ? column.header : rowValue(row, column);
|
|
87
|
-
const rendered = fit(value, widths[index], header ? null : column.
|
|
87
|
+
const rendered = fit(value, widths[index], header ? null : column.truncate);
|
|
88
88
|
const padding = Math.max(0, widths[index] - displayWidth(rendered));
|
|
89
89
|
if (index === columns.length - 1) {
|
|
90
90
|
return column.align === 'right' ? `${' '.repeat(padding)}${rendered}` : rendered;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lyupro/codex-bridge",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Claude Code-to-Codex delegation with 6 guards,
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "Claude Code-to-Codex delegation with 6 guards, 961 tests, zero runtime dependencies, and zero build steps.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude-code",
|
|
7
7
|
"codex",
|
|
@@ -23,9 +23,9 @@ opinion, not a verdict.
|
|
|
23
23
|
## What you receive as input
|
|
24
24
|
|
|
25
25
|
- What to review. One of these modes:
|
|
26
|
-
- uncommitted changes (default) — `--
|
|
27
|
-
- branch against base — `--
|
|
28
|
-
- specific commit — `--
|
|
26
|
+
- uncommitted changes (default) — `--changeset uncommitted`;
|
|
27
|
+
- branch against base — `--changeset base:<branch>`;
|
|
28
|
+
- specific commit — `--changeset commit:<sha>`.
|
|
29
29
|
- The path to the repository. If none is given, use the current working directory.
|
|
30
30
|
- The path to a task file containing the review focus verbatim. The orchestrator supplies this
|
|
31
31
|
path; pass it as `--task-file` and never create, read or rewrite it. Writing that file yourself
|
|
@@ -72,7 +72,7 @@ three in sequence and ended by telling its operator to grant a rule on `run-code
|
|
|
72
72
|
## The only thing you do
|
|
73
73
|
|
|
74
74
|
```bash
|
|
75
|
-
codex-bridge run --agent codex-review --repo "<repository-path or .>" --
|
|
75
|
+
codex-bridge run --agent codex-review --repo "<repository-path or .>" --changeset "<one changeset from the list above>" --slug "<slug>" --order-id "<order id from the orchestrator>" --task-file "<task-file path from the orchestrator>"
|
|
76
76
|
```
|
|
77
77
|
|
|
78
78
|
Add `--effort "<value>"` only when the orchestrator named a depth, and only with one of
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Show, list or change the model and reasoning depth each delegated Codex role runs on
|
|
3
|
+
allowed-tools: Bash
|
|
4
|
+
argument-hint: "[list | set <role> <model> [effort] | unset <role> | speed <role> <tier> [confirm] | speed <role> unset]"
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
<!-- Part of the agents/codex-bridge/ package. The file lives here out of necessity: the slash
|
|
8
|
+
command name is set by its location under commands/<namespace>/, and Claude Code understands neither
|
|
9
|
+
symlinks nor pointer files (verified 2026-08-02). Edit it here; it travels as a copy when the
|
|
10
|
+
package is installed. -->
|
|
11
|
+
|
|
12
|
+
Which model each delegated role runs on. The three roles are the scout, the builder and the
|
|
13
|
+
reviewer, and each carries its own model and reasoning depth.
|
|
14
|
+
|
|
15
|
+
Run exactly this command, substituting the user's arguments (`$ARGUMENTS`), and return its output
|
|
16
|
+
verbatim. Add nothing and recompute nothing:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
codex-bridge model $ARGUMENTS
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Unlike the older `/codex-bridge:env`, this calls the short package command rather than a path to a
|
|
23
|
+
file (Plan_56 D1). The operator's standing permission rule is written for that spelling, and moving
|
|
24
|
+
the installed files can no longer break the call.
|
|
25
|
+
|
|
26
|
+
Call forms:
|
|
27
|
+
|
|
28
|
+
- no arguments — the model, reasoning depth, pinned speed tier and provenance of each role, plus the path of the
|
|
29
|
+
config file;
|
|
30
|
+
- `list` — the live catalogue from Codex: every model, the reasoning levels it accepts, its default
|
|
31
|
+
level and its accelerated service tier identifiers (parallel speed labels are not extra tiers);
|
|
32
|
+
- `set <role> <model> [effort]` — pin one role, with `--model` and `--effort` accepted instead of
|
|
33
|
+
the positions. The pair is checked against that model's own catalogue entry before anything is
|
|
34
|
+
written, so an unsupported depth is refused with the depths that model does accept, and a role
|
|
35
|
+
whose existing depth or pinned speed tier the new model cannot keep is refused with its supported values;
|
|
36
|
+
- `unset <role>` — remove that role's profile, returning it to whatever Codex chooses;
|
|
37
|
+
- `speed <role> <tier>` — preview an accelerated tier from the pinned model's live catalogue entry.
|
|
38
|
+
Pin a model with `set` first. The preview quotes the tier description verbatim and separately links
|
|
39
|
+
to https://learn.chatgpt.com/docs/agent-configuration/speed for credit multipliers; it writes nothing;
|
|
40
|
+
- `speed <role> <tier> confirm` — fetch and validate again, then pin that exact tier and report the
|
|
41
|
+
previous and new profile. Unknown tiers or an unavailable catalogue are refused without a write;
|
|
42
|
+
- `speed <role> unset` — remove only the speed pin immediately, preserving model and effort.
|
|
43
|
+
This needs no confirmation or catalogue; with no pin the package does not interfere with speed.
|
|
44
|
+
|
|
45
|
+
Two things the output says that are worth repeating to the operator if they ask:
|
|
46
|
+
|
|
47
|
+
- The profile is **machine-wide**. One config file serves every project on this machine, so
|
|
48
|
+
changing a role's model changes it for all of them, not for the repository you are standing in.
|
|
49
|
+
- The catalogue is asked live every time. There is no cache and no bundled copy, because the copy
|
|
50
|
+
inside the Codex binary lists models the server no longer has — an operator who picked one from a
|
|
51
|
+
stale list would lose the quota before the run started.
|
|
52
|
+
|
|
53
|
+
`set` and `unset` apply immediately; pinning a paid speed tier requires the second call ending in
|
|
54
|
+
`confirm`. Never edit the config file directly to do what these commands do — model, effort and
|
|
55
|
+
tier pins are checked against the live catalogue before writing.
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import fs from 'node:fs';
|
|
15
15
|
import os from 'node:os';
|
|
16
16
|
import path from 'node:path';
|
|
17
|
+
import { AGENTS } from '../lib/agents.mjs';
|
|
17
18
|
import { readJsonFileSync } from '../lib/json-file.mjs';
|
|
18
19
|
import {
|
|
19
20
|
diagnoseInput,
|
|
@@ -31,7 +32,7 @@ import { parseTaskDocument } from '../lib/runner/task-file.mjs';
|
|
|
31
32
|
|
|
32
33
|
const HOME = os.homedir();
|
|
33
34
|
const LOG_DIR = path.join(HOME, '.claude', 'logs');
|
|
34
|
-
const GUARDED = new Set(
|
|
35
|
+
const GUARDED = new Set(Object.keys(AGENTS));
|
|
35
36
|
/**
|
|
36
37
|
* Both spellings of the subagent-launching tool, from the same list the installer builds its
|
|
37
38
|
* matcher from. Recognising only the name this host happens to use would make the gate silent
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
import fs from 'node:fs';
|
|
30
30
|
import os from 'node:os';
|
|
31
31
|
import path from 'node:path';
|
|
32
|
+
import { AGENTS } from '../lib/agents.mjs';
|
|
32
33
|
import { runOrderMismatch, transcriptOrderId } from '../lib/dispatcher-order.mjs';
|
|
33
34
|
import { recognizeHostRefusal } from '../lib/host-refusal.mjs';
|
|
34
35
|
import { readJsonFileSync } from '../lib/json-file.mjs';
|
|
@@ -65,7 +66,7 @@ import {
|
|
|
65
66
|
// The broader hook-directory scan protects the extracted verdict text from the same incident.
|
|
66
67
|
const HOME = os.homedir();
|
|
67
68
|
const LOG_DIR = path.join(HOME, '.claude', 'logs');
|
|
68
|
-
const GUARDED = new Set(
|
|
69
|
+
const GUARDED = new Set(Object.keys(AGENTS));
|
|
69
70
|
|
|
70
71
|
/**
|
|
71
72
|
* How many times a single agent may be blocked over the SHAPE of its reply — no RUN= line,
|