@lyupro/codex-bridge 0.6.6 → 0.6.7
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 +40 -0
- package/README.md +5 -5
- package/bin/codex-bridge.mjs +181 -178
- package/cli/dispatcher-contract-record.mjs +42 -0
- package/cli/dispatcher-contract.mjs +89 -0
- package/cli/doctor-hooks.mjs +104 -114
- package/cli/doctor-runs.mjs +55 -55
- package/cli/doctor.mjs +39 -3
- package/cli/handback-witness-check.mjs +39 -0
- package/cli/hook-targets.mjs +14 -6
- package/cli/hook.mjs +32 -23
- package/cli/host-contract.mjs +136 -135
- package/cli/install.mjs +9 -14
- package/cli/launcher-probe.mjs +38 -0
- package/cli/probe-contract.mjs +31 -62
- package/cli/probe-rig.mjs +53 -0
- package/cli/run-launcher.mjs +17 -0
- package/cli/settings-merge.mjs +42 -35
- package/cli/uninstall.mjs +15 -3
- package/cli/update.mjs +45 -16
- package/package.json +4 -4
- package/src/agents/codex-advisor.md +3 -4
- package/src/agents/codex-build.md +5 -5
- package/src/agents/codex-review.md +3 -3
- package/src/agents/codex-scout.md +3 -4
- package/src/home/hooks/dispatcher-gate.mjs +119 -0
- package/src/home/hooks/guard-tries.mjs +5 -6
- package/src/home/hooks/live-runs.mjs +1 -0
- package/src/home/hooks/order-gate.mjs +20 -5
- package/src/home/hooks/reply-guard.mjs +75 -10
- package/src/home/hooks/reply-verdicts.mjs +5 -0
- package/src/home/hooks/worktree-lock.mjs +108 -107
- package/src/home/lib/atomic-json.mjs +21 -0
- package/src/home/lib/brand-home.mjs +6 -0
- package/src/home/lib/dispatcher-command.mjs +79 -0
- package/src/home/lib/dispatcher-gate.mjs +95 -0
- package/src/home/lib/dispatcher-order.mjs +30 -8
- package/src/home/lib/dispatcher-state.mjs +111 -0
- package/src/home/lib/dispatcher-stop.mjs +52 -0
- package/src/home/lib/handback-witness.mjs +65 -0
- package/src/home/lib/hook-definitions.mjs +10 -0
- package/src/home/lib/hook-entry.mjs +20 -0
- package/src/home/lib/meta/advice-verdict.mjs +37 -21
- package/src/home/lib/required-inputs.mjs +1 -1
- package/src/home/lib/runner/codex-cmd.mjs +3 -2
- package/src/home/lib/runner/git-state.mjs +1 -1
- package/src/home/lib/runner/preflight.mjs +6 -3
- package/src/home/lib/runner/prompts.mjs +8 -7
- package/src/home/lib/runner/schemas.mjs +33 -7
package/cli/settings-merge.mjs
CHANGED
|
@@ -57,44 +57,45 @@ export function commandReachable(name = 'codex-bridge', env = process.env) {
|
|
|
57
57
|
* could decide anything, and the host refused Bash, PowerShell, file edits and agent launches.
|
|
58
58
|
*/
|
|
59
59
|
export function reachableCommandVersion(name = 'codex-bridge', env = process.env) {
|
|
60
|
+
const result = runReachableCommand(name, ['--version'], env);
|
|
61
|
+
if (!result || result.status !== 0) return null;
|
|
62
|
+
return String(result.stdout || '').trim().split(/\r?\n/).pop()?.trim() || null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function runReachableCommand(name, args, env = process.env) {
|
|
60
66
|
const shimPath = resolveCommand(name, env);
|
|
61
67
|
if (!shimPath) return null;
|
|
62
68
|
// Node 24 warned with DEP0190 during update (2026-09-07): invoke cmd explicitly for npm shims.
|
|
63
69
|
// Live probes covered plain, bin with space, Program Files (x86), weird & name, and caret^dir.
|
|
64
|
-
// /d /s /c with path and
|
|
70
|
+
// /d /s /c with path and fixed arguments as separate arguments only passed the plain directory:
|
|
65
71
|
// Node adds quotes, then cmd's /s strips the first and last quote of the whole command line.
|
|
66
72
|
// /d /c without /s failed on & and ^. This single, double-wrapped verbatim command passed all
|
|
67
73
|
// five. Keep native path separators: forward-slash Windows paths do not work through cmd.
|
|
68
74
|
// The interpreter is a property of the machine, not of the PATH handed in: resolving cmd.exe
|
|
69
|
-
// through the caller's PATH would return null on any env whose PATH omits System32,
|
|
70
|
-
//
|
|
75
|
+
// through the caller's PATH would return null on any env whose PATH omits System32, hiding the
|
|
76
|
+
// exact command outcome this probe exists to report.
|
|
71
77
|
const shell = envValue(env, 'comspec') || process.env.ComSpec || 'cmd.exe';
|
|
78
|
+
const fixedArgs = args.map((argument) => ` ${argument}`).join('');
|
|
72
79
|
const result = process.platform === 'win32'
|
|
73
|
-
? spawnSync(shell, ['/d', '/s', '/c', `""${shimPath}"
|
|
74
|
-
encoding: 'utf8', env, shell: false, windowsVerbatimArguments: true,
|
|
80
|
+
? spawnSync(shell, ['/d', '/s', '/c', `""${shimPath}"${fixedArgs}"`], {
|
|
81
|
+
encoding: 'utf8', env, shell: false, windowsVerbatimArguments: true, windowsHide: true,
|
|
82
|
+
timeout: 10000, maxBuffer: 64 * 1024,
|
|
75
83
|
})
|
|
76
|
-
: spawnSync(shimPath,
|
|
77
|
-
|
|
78
|
-
|
|
84
|
+
: spawnSync(shimPath, args, {
|
|
85
|
+
encoding: 'utf8', env, shell: false, windowsHide: true, timeout: 10000, maxBuffer: 64 * 1024,
|
|
86
|
+
});
|
|
87
|
+
return { path: shimPath, status: result.status, stdout: String(result.stdout || '') };
|
|
79
88
|
}
|
|
80
89
|
|
|
81
|
-
export function hookRegistration(name, target,
|
|
82
|
-
|
|
83
|
-
// a command of another version would run code this install has never seen, which is the hazard
|
|
84
|
-
// README describes when a global install and a clone coexist. Anything short of a match falls
|
|
85
|
-
// back to the copy this install placed itself.
|
|
86
|
-
const reachable = reachableCommandVersion('codex-bridge', env);
|
|
87
|
-
if (reachable && packageVersion && reachable === packageVersion) {
|
|
90
|
+
export function hookRegistration(name, target, probe) {
|
|
91
|
+
if (probe.ok) {
|
|
88
92
|
return {
|
|
89
93
|
command: shortCommandFor(name),
|
|
90
94
|
form: 'short',
|
|
91
|
-
reason: `codex-bridge
|
|
95
|
+
reason: `codex-bridge on PATH launches guards from this home (${probe.homeRoot})`,
|
|
92
96
|
};
|
|
93
97
|
}
|
|
94
|
-
|
|
95
|
-
? `codex-bridge on PATH reports ${reachable}, not ${packageVersion}; the installed copy will execute`
|
|
96
|
-
: 'codex-bridge is not reachable from PATH; the installed copy will execute';
|
|
97
|
-
return { command: commandFor(target), form: 'path', reason };
|
|
98
|
+
return { command: commandFor(target), form: 'path', reason: `${probe.reason}; the installed copy will execute` };
|
|
98
99
|
}
|
|
99
100
|
|
|
100
101
|
export function commandForm(command) {
|
|
@@ -163,7 +164,9 @@ const hasOwnCommand = (group, commands) => {
|
|
|
163
164
|
* deleted file. Conversely, the 2026-08-17 host proved that finding alone is insufficient: its
|
|
164
165
|
* worktree lock stayed under the pre-shell matcher, so Bash never reached the guard. The command
|
|
165
166
|
* is this package's own absolute path, so it identifies our entry wherever the group ended up,
|
|
166
|
-
* and merge can move it without creating a duplicate.
|
|
167
|
+
* and merge can move it without creating a duplicate. Alternate commands identify our entry, but
|
|
168
|
+
* do not prove it is in the launcher form selected for this operation. Update must rewrite stale
|
|
169
|
+
* forms, or unsafe registrations already in settings.json would survive every future update.
|
|
167
170
|
*/
|
|
168
171
|
export async function inspectHook(settingsPath, specOrPath) {
|
|
169
172
|
const state = await readSettings(settingsPath);
|
|
@@ -172,11 +175,15 @@ export async function inspectHook(settingsPath, specOrPath) {
|
|
|
172
175
|
let matchedCommand;
|
|
173
176
|
let matchedMatcher;
|
|
174
177
|
let requiresMove = false;
|
|
178
|
+
let requiresRewrite = false;
|
|
179
|
+
let duplicates = 0;
|
|
175
180
|
for (const group of groups(state.settings, spec.event)) {
|
|
176
181
|
for (const hook of groupHooks(group)) {
|
|
177
182
|
if (hook?.type !== 'command' || !commands.includes(hook.command)) continue;
|
|
178
183
|
matchedCommand ??= hook.command;
|
|
179
184
|
matchedMatcher ??= group?.matcher;
|
|
185
|
+
duplicates += 1;
|
|
186
|
+
if (hook.command !== spec.command) requiresRewrite = true;
|
|
180
187
|
if (group?.matcher !== spec.matcher) requiresMove = true;
|
|
181
188
|
}
|
|
182
189
|
}
|
|
@@ -187,6 +194,9 @@ export async function inspectHook(settingsPath, specOrPath) {
|
|
|
187
194
|
matchedCommand,
|
|
188
195
|
matchedMatcher,
|
|
189
196
|
requiresMove,
|
|
197
|
+
requiresRewrite,
|
|
198
|
+
duplicates: duplicates > 1,
|
|
199
|
+
current: Boolean(matchedCommand) && !requiresMove && !requiresRewrite && duplicates === 1,
|
|
190
200
|
};
|
|
191
201
|
}
|
|
192
202
|
|
|
@@ -258,15 +268,17 @@ export async function mergeHook(settingsPath, specOrPath, inspected) {
|
|
|
258
268
|
return withMutationRun(settingsPath, async () => {
|
|
259
269
|
const spec = normalizeSpec(specOrPath);
|
|
260
270
|
const state = inspected || await inspectHook(settingsPath, spec);
|
|
261
|
-
if (state.
|
|
271
|
+
if (state.current) return { changed: false, createdGroup: false };
|
|
262
272
|
const settings = structuredClone(state.settings);
|
|
263
273
|
settings.hooks ??= {};
|
|
264
274
|
if (!Array.isArray(settings.hooks[spec.event])) settings.hooks[spec.event] = [];
|
|
265
275
|
if (state.present) {
|
|
266
276
|
const eventGroups = settings.hooks[spec.event];
|
|
267
277
|
const commands = [spec.command, ...spec.alternateCommands];
|
|
278
|
+
// The entry in the declared group wins, so a field the operator added there (a timeout)
|
|
279
|
+
// survives the rewrite; groups are walked from the end, which would otherwise pick a stale copy.
|
|
268
280
|
let hookToMove;
|
|
269
|
-
let
|
|
281
|
+
let declaredHook;
|
|
270
282
|
for (let index = eventGroups.length - 1; index >= 0; index -= 1) {
|
|
271
283
|
const group = eventGroups[index];
|
|
272
284
|
if (!Array.isArray(group?.hooks)) continue;
|
|
@@ -274,26 +286,21 @@ export async function mergeHook(settingsPath, specOrPath, inspected) {
|
|
|
274
286
|
group.hooks = group.hooks.filter((hook) => {
|
|
275
287
|
if (hook?.type !== 'command' || !commands.includes(hook.command)) return true;
|
|
276
288
|
hookToMove ??= hook;
|
|
277
|
-
if (group.matcher === spec.matcher
|
|
278
|
-
keptDeclaredHook = true;
|
|
279
|
-
return true;
|
|
280
|
-
}
|
|
289
|
+
if (group.matcher === spec.matcher) declaredHook ??= hook;
|
|
281
290
|
return false;
|
|
282
291
|
});
|
|
283
292
|
if (hadOwnHook && group.hooks.length === 0) eventGroups.splice(index, 1);
|
|
284
293
|
}
|
|
285
294
|
let group = eventGroups.find((entry) => entry?.matcher === spec.matcher);
|
|
286
295
|
const createdGroup = !group;
|
|
287
|
-
if (!
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
eventGroups.push(group);
|
|
291
|
-
}
|
|
292
|
-
if (!Array.isArray(group.hooks)) group.hooks = [];
|
|
293
|
-
group.hooks.push(hookToMove);
|
|
296
|
+
if (!group) {
|
|
297
|
+
group = { matcher: spec.matcher, hooks: [] };
|
|
298
|
+
eventGroups.push(group);
|
|
294
299
|
}
|
|
300
|
+
if (!Array.isArray(group.hooks)) group.hooks = [];
|
|
301
|
+
group.hooks.push({ ...(declaredHook || hookToMove), command: spec.command });
|
|
295
302
|
await atomicWrite(settingsPath, settings, state);
|
|
296
|
-
return { changed: true, createdGroup, moved: true };
|
|
303
|
+
return { changed: true, createdGroup, ...(state.requiresMove ? { moved: true } : {}), ...(state.requiresRewrite ? { rewritten: true } : {}) };
|
|
297
304
|
}
|
|
298
305
|
let group = settings.hooks[spec.event].find((entry) => entry?.matcher === spec.matcher);
|
|
299
306
|
const createdGroup = !group;
|
package/cli/uninstall.mjs
CHANGED
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
recordTarget,
|
|
11
11
|
} from './manifest.mjs';
|
|
12
12
|
import { removePermissionRules } from './permissions.mjs';
|
|
13
|
+
import { hostContractPath } from './host-contract.mjs';
|
|
14
|
+
import { brandStateDir } from '../src/home/lib/brand-home.mjs';
|
|
13
15
|
import {
|
|
14
16
|
commandFor,
|
|
15
17
|
removeHook,
|
|
@@ -33,6 +35,17 @@ function permissionOutput(host, removed, dryRun) {
|
|
|
33
35
|
return `${verb} ${removed} permission rule ${plural} from ${host.settingsPath}.`;
|
|
34
36
|
}
|
|
35
37
|
|
|
38
|
+
// Plan_62 D22: uninstall removes only recorded files, and the message used to name only runs and
|
|
39
|
+
// config.json — state/, the host measurement and conventions.md stayed behind unmentioned. Until
|
|
40
|
+
// Plan_65 derives this from a registry, the sentence names every kind the package writes while working.
|
|
41
|
+
function preservationText(host) {
|
|
42
|
+
return `Run artifacts in ${path.join(host.root, 'codex-runs')} are preserved, and so is what the package `
|
|
43
|
+
+ `wrote into ${host.brandRoot} while working: the run configuration ${host.brandConfigPath}, the conventions `
|
|
44
|
+
+ `${host.brandConventionsPath}, the host measurement ${hostContractPath(host)} and runtime state in `
|
|
45
|
+
+ `${brandStateDir(host.brandRoot)} (dispatcher state, guard counters, the handback witness, dispatcher `
|
|
46
|
+
+ 'contract verdicts, and hook diagnostics that hold the full hook input). Delete them by hand for a complete removal.';
|
|
47
|
+
}
|
|
48
|
+
|
|
36
49
|
function displayFile(file) {
|
|
37
50
|
return `${file.root}/${file.path}`;
|
|
38
51
|
}
|
|
@@ -62,8 +75,7 @@ async function uninstallInRun({ host, dryRun = false } = {}) {
|
|
|
62
75
|
const permissionResult = await removePermissionRules(host.settingsPath, { dryRun });
|
|
63
76
|
const permissionLine = permissionOutput(host, permissionResult.removed, dryRun);
|
|
64
77
|
const record = await readInstallRecord(host);
|
|
65
|
-
const preservation =
|
|
66
|
-
+ `configuration in ${host.brandConfigPath} are preserved.`;
|
|
78
|
+
const preservation = preservationText(host);
|
|
67
79
|
if (!record) {
|
|
68
80
|
return { exitCode: 1, output: `${permissionLine}\ncodex-bridge is not installed.\n${preservation}` };
|
|
69
81
|
}
|
|
@@ -92,7 +104,7 @@ async function uninstallInRun({ host, dryRun = false } = {}) {
|
|
|
92
104
|
}
|
|
93
105
|
for (const hook of record.hooks) {
|
|
94
106
|
const definition = definitionForRecordedHook(hook);
|
|
95
|
-
lines.push(`Would remove the ${hook.event} hook for matcher ${definition.matcher}.`);
|
|
107
|
+
lines.push(`Would remove the ${hook.event} hook ${definition.name} for matcher ${definition.matcher}.`);
|
|
96
108
|
}
|
|
97
109
|
lines.push('Would remove the installation record from the brand root.');
|
|
98
110
|
lines.push(preservation);
|
package/cli/update.mjs
CHANGED
|
@@ -17,11 +17,10 @@ import {
|
|
|
17
17
|
rulesPlan,
|
|
18
18
|
} from './manifest.mjs';
|
|
19
19
|
import { targetMatches } from './copy.mjs';
|
|
20
|
-
import { hookTargets } from './hook-targets.mjs';
|
|
20
|
+
import { hookTargets, recordHasHooks } from './hook-targets.mjs';
|
|
21
21
|
import { definitionForRecordedHook, fileEntry, fingerprintFor } from './install-record.mjs';
|
|
22
22
|
import {
|
|
23
23
|
commandFor,
|
|
24
|
-
hookRegistration,
|
|
25
24
|
inspectHook,
|
|
26
25
|
removeHook,
|
|
27
26
|
withSettingsRun,
|
|
@@ -29,6 +28,12 @@ import {
|
|
|
29
28
|
import { addRulesOwner, readRulesRegistry } from './rules-owners.mjs';
|
|
30
29
|
import { claudeBoundary, removeEmptyLayout, removeEmptyParents } from './remove-layout.mjs';
|
|
31
30
|
|
|
31
|
+
const LEGACY_GUARD_LOG_FILES = [
|
|
32
|
+
'codex-reply-guard.blocked.json',
|
|
33
|
+
'codex-order-gate.last.json',
|
|
34
|
+
'codex-reply-guard.last.json',
|
|
35
|
+
];
|
|
36
|
+
|
|
32
37
|
async function exists(target) {
|
|
33
38
|
try {
|
|
34
39
|
return (await fs.stat(target)).isFile();
|
|
@@ -45,6 +50,18 @@ async function retireLegacyLayout(host) {
|
|
|
45
50
|
await removeEmptyLayout(host.legacyCommandsDir);
|
|
46
51
|
}
|
|
47
52
|
|
|
53
|
+
async function retireLegacyGuardLogs(host, { dryRun = false } = {}) {
|
|
54
|
+
const logDir = path.join(path.dirname(host.settingsPath), 'logs');
|
|
55
|
+
const lines = [];
|
|
56
|
+
for (const name of LEGACY_GUARD_LOG_FILES) {
|
|
57
|
+
const target = path.join(logDir, name);
|
|
58
|
+
if (!await exists(target)) continue;
|
|
59
|
+
if (!dryRun) await fs.rm(target, { force: true });
|
|
60
|
+
lines.push(`${dryRun ? 'Would remove' : 'Removed'} ${path.join('logs', name)}.`);
|
|
61
|
+
}
|
|
62
|
+
return lines.join('\n');
|
|
63
|
+
}
|
|
64
|
+
|
|
48
65
|
function displayFile(file) {
|
|
49
66
|
const entry = fileEntry(file);
|
|
50
67
|
return `${entry.root}/${entry.path}`;
|
|
@@ -64,12 +81,6 @@ function classifyOrphan(state) {
|
|
|
64
81
|
return 'modified';
|
|
65
82
|
}
|
|
66
83
|
|
|
67
|
-
function recordHasHooks(record, targets) {
|
|
68
|
-
return Boolean(record)
|
|
69
|
-
&& targets.every(({ definition, relative }) => record.hooks?.some((hook) =>
|
|
70
|
-
hook.event === definition.event && hook.root === 'brand' && hook.path === relative));
|
|
71
|
-
}
|
|
72
|
-
|
|
73
84
|
function appliedOutput(states) {
|
|
74
85
|
const count = (status) => states.filter((state) => state.status === status).length;
|
|
75
86
|
const parts = [
|
|
@@ -104,12 +115,14 @@ function dryRunOutput(states, hookStates, legacy, oldHooks) {
|
|
|
104
115
|
// "the previous undefined hook registration" five times during the Plan_25 migration — the one
|
|
105
116
|
// dry run where the operator most needed to see which registrations were about to be taken away.
|
|
106
117
|
oldHooks.forEach(({ hook, definition }) => lines.push(
|
|
107
|
-
`Would remove the previous ${hook.event} hook registration for matcher ${definition.matcher}.`,
|
|
118
|
+
`Would remove the previous ${hook.event} hook registration ${definition.name} for matcher ${definition.matcher}.`,
|
|
108
119
|
));
|
|
109
120
|
hookStates.forEach(({ state, target }) => {
|
|
110
121
|
const { definition, registration } = target;
|
|
111
|
-
if (!state.
|
|
112
|
-
lines.push(`Would
|
|
122
|
+
if (state.present && !state.current) {
|
|
123
|
+
lines.push(`Would rewrite ${definition.event} hook ${definition.name} for matcher ${definition.matcher} to ${registration.form} command: ${registration.reason}.`);
|
|
124
|
+
} else if (!state.present) {
|
|
125
|
+
lines.push(`Would register ${definition.event} hook ${definition.name} for matcher ${definition.matcher} with ${registration.form} command.`);
|
|
113
126
|
}
|
|
114
127
|
});
|
|
115
128
|
lines.push('Would write installation record with new fingerprints in both roots.');
|
|
@@ -210,7 +223,7 @@ async function updateInRun({ host, dryRun = false, force = false, packageRoot, e
|
|
|
210
223
|
+ ' The home was updated from the copy that answered, not from that checkout'
|
|
211
224
|
+ ` (npm i -g ${currentPackage.name}@${elsewhere.version} changes which one that is).`
|
|
212
225
|
: '';
|
|
213
|
-
const targets = hookTargets(host, env
|
|
226
|
+
const targets = hookTargets(host, env);
|
|
214
227
|
const inspectedHooks = await Promise.all(targets.map(async (target) => ({
|
|
215
228
|
target,
|
|
216
229
|
state: await inspectHook(host.settingsPath, target.spec),
|
|
@@ -220,8 +233,9 @@ async function updateInRun({ host, dryRun = false, force = false, packageRoot, e
|
|
|
220
233
|
new Map(plannedStates.map((state) => [recordFileKey(state.item), state.fingerprint])),
|
|
221
234
|
{ path: rule.target, fingerprint: ruleState.fingerprint });
|
|
222
235
|
const changed = states.some((state) => state.status !== 'up-to-date') || oldHooks.length > 0;
|
|
223
|
-
if (!changed && inspectedHooks.every(({ state }) => state.
|
|
236
|
+
if (!changed && inspectedHooks.every(({ state }) => state.current)
|
|
224
237
|
&& recordHasHooks(record, targets) && recordCurrent) {
|
|
238
|
+
const legacyLogOutput = await retireLegacyGuardLogs(host, { dryRun });
|
|
225
239
|
if (!dryRun) {
|
|
226
240
|
await addRulesOwner(host);
|
|
227
241
|
// An update that installed the new layout but stopped before retiring the old one used to
|
|
@@ -230,10 +244,20 @@ async function updateInRun({ host, dryRun = false, force = false, packageRoot, e
|
|
|
230
244
|
// it on this path costs nothing when there is nothing left to retire.
|
|
231
245
|
await retireLegacyLayout(host);
|
|
232
246
|
}
|
|
233
|
-
return {
|
|
247
|
+
return {
|
|
248
|
+
exitCode: 0,
|
|
249
|
+
output: [`codex-bridge is up to date with ${source}${mismatch}`, legacyLogOutput]
|
|
250
|
+
.filter(Boolean).join('\n'),
|
|
251
|
+
};
|
|
234
252
|
}
|
|
235
253
|
if (dryRun) {
|
|
236
|
-
|
|
254
|
+
const legacyLogOutput = await retireLegacyGuardLogs(host, { dryRun: true });
|
|
255
|
+
return {
|
|
256
|
+
exitCode: 0,
|
|
257
|
+
output: [`Would update codex-bridge with ${source}.${mismatch}`,
|
|
258
|
+
dryRunOutput(states, inspectedHooks, legacy, oldHooks), legacyLogOutput]
|
|
259
|
+
.filter(Boolean).join('\n'),
|
|
260
|
+
};
|
|
237
261
|
}
|
|
238
262
|
|
|
239
263
|
for (const state of orphanStates) {
|
|
@@ -249,7 +273,12 @@ async function updateInRun({ host, dryRun = false, force = false, packageRoot, e
|
|
|
249
273
|
const installed = await install({ host, force: true, packageRoot, env });
|
|
250
274
|
if (installed.exitCode !== 0) return installed;
|
|
251
275
|
await retireLegacyLayout(host);
|
|
252
|
-
|
|
276
|
+
const legacyLogOutput = await retireLegacyGuardLogs(host);
|
|
277
|
+
return {
|
|
278
|
+
exitCode: 0,
|
|
279
|
+
output: [`${appliedOutput(states)}\nSource: ${source}${mismatch}`, legacyLogOutput]
|
|
280
|
+
.filter(Boolean).join('\n'),
|
|
281
|
+
};
|
|
253
282
|
}
|
|
254
283
|
|
|
255
284
|
export async function update(options = {}) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lyupro/codex-bridge",
|
|
3
|
-
"version": "0.6.
|
|
4
|
-
"description": "Claude Code-to-Codex delegation with
|
|
3
|
+
"version": "0.6.7",
|
|
4
|
+
"description": "Claude Code-to-Codex delegation with 7 guards, 1636 tests, zero runtime dependencies, and zero build steps.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude-code",
|
|
7
7
|
"codex",
|
|
@@ -23,8 +23,8 @@
|
|
|
23
23
|
"homepage": "https://github.com/lyupro/codex-bridge#readme",
|
|
24
24
|
"type": "module",
|
|
25
25
|
"bin": {
|
|
26
|
-
"codex-bridge": "
|
|
27
|
-
"codexb": "
|
|
26
|
+
"codex-bridge": "bin/codex-bridge.mjs",
|
|
27
|
+
"codexb": "bin/codex-bridge.mjs"
|
|
28
28
|
},
|
|
29
29
|
"engines": {
|
|
30
30
|
"node": ">=24.0.0"
|
|
@@ -103,10 +103,9 @@ differs — that refusal is the whole answer: return `FAIL` with the runner's te
|
|
|
103
103
|
a different id of your own choosing; the order id is the orchestrator's.
|
|
104
104
|
|
|
105
105
|
Background execution (`run_in_background`, `&`, `nohup`) is prohibited, and so is inventing a
|
|
106
|
-
report from memory. If
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
same order id and without `--no-wait`. A real run takes 20-25 minutes, which is normal, not a hang.
|
|
106
|
+
report from memory. If the attaching call is killed by a time ceiling, run the identical command again: it attaches to the same
|
|
107
|
+
run and keeps waiting. Never add a flag — the host allows exactly the one command your
|
|
108
|
+
order yields and refuses every other. A real run takes 20-25 minutes, which is normal, not a hang.
|
|
110
109
|
Give the ordinary attaching call `timeout: 1800000` (30 minutes).
|
|
111
110
|
|
|
112
111
|
**Never change `--order-id` or `--slug` to get a fresh run.** The order id is issued by the
|
|
@@ -52,7 +52,8 @@ job is to report the run status honestly, including failure.
|
|
|
52
52
|
operator's real check command contained `&&`, and a compound operator in an argument makes the
|
|
53
53
|
host stop applying its permission rule, which is how a delegation dies on a refusal.
|
|
54
54
|
- Every build task file must contain exactly one `advice:` line from the orchestrator: an absolute
|
|
55
|
-
path to an existing run folder whose `meta.json` identifies `codex-advisor
|
|
55
|
+
path to an existing run folder whose `meta.json` identifies a `codex-advisor` advise pass with
|
|
56
|
+
status `OK`, or exactly
|
|
56
57
|
`mechanical`, `revert`, `docs-only`, or `test-only`. Anything else is refused before the run
|
|
57
58
|
starts. Pass the task file as supplied; the orchestrator owns this line.
|
|
58
59
|
|
|
@@ -111,10 +112,9 @@ differs — that refusal is the whole answer: return `FAIL` with the runner's te
|
|
|
111
112
|
remedy. Never retry under a different id of your own choosing; the order id is the orchestrator's.
|
|
112
113
|
|
|
113
114
|
Background execution (`run_in_background`, `&`, `nohup`) is prohibited, and so is inventing a report
|
|
114
|
-
from memory. If the attaching call is killed by a time ceiling,
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
and without `--no-wait`. A real run takes 20-25 minutes, which is normal, not a hang. Give the ordinary
|
|
115
|
+
from memory. If the attaching call is killed by a time ceiling, run the identical command again: it attaches to the same
|
|
116
|
+
run and keeps waiting. Never add a flag — the host allows exactly the one command your
|
|
117
|
+
order yields and refuses every other. A real run takes 20-25 minutes, which is normal, not a hang. Give the ordinary
|
|
118
118
|
attaching call `timeout: 1800000` (30 minutes).
|
|
119
119
|
|
|
120
120
|
**Never change `--order-id` or `--slug` to get a fresh run.** The order id is issued by the
|
|
@@ -96,9 +96,9 @@ the runner parses it and issues the refusal. If no such grant line is present, t
|
|
|
96
96
|
present in the command at all.
|
|
97
97
|
|
|
98
98
|
Background execution (`run_in_background`, `&`, `nohup`) is prohibited. If the attaching call is killed
|
|
99
|
-
by a time ceiling,
|
|
100
|
-
|
|
101
|
-
|
|
99
|
+
by a time ceiling, run the identical command again: it attaches to the same
|
|
100
|
+
run and keeps waiting. Never add a flag — the host allows exactly the one command your
|
|
101
|
+
order yields and refuses every other. A real run takes
|
|
102
102
|
20-25 minutes, which is normal, not a hang. Give the ordinary attaching call `timeout: 1800000` (30
|
|
103
103
|
minutes).
|
|
104
104
|
|
|
@@ -107,10 +107,9 @@ differs — that refusal is the whole answer: return `FAIL` with the runner's te
|
|
|
107
107
|
remedy. Never retry under a different id of your own choosing; the order id is the orchestrator's.
|
|
108
108
|
|
|
109
109
|
Background execution (`run_in_background`, `&`, `nohup`) is prohibited, and so is inventing a report
|
|
110
|
-
from memory. If the attaching call is killed by a time ceiling,
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
and without `--no-wait`. A real run takes 20-25 minutes, which is normal, not a hang. Give the ordinary
|
|
110
|
+
from memory. If the attaching call is killed by a time ceiling, run the identical command again: it attaches to the same
|
|
111
|
+
run and keeps waiting. Never add a flag — the host allows exactly the one command your
|
|
112
|
+
order yields and refuses every other. A real run takes 20-25 minutes, which is normal, not a hang. Give the ordinary
|
|
114
113
|
attaching call `timeout: 1800000` (30 minutes).
|
|
115
114
|
|
|
116
115
|
**Never change `--order-id` or `--slug` to get a fresh run.** The order id is issued by the
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** Enforces each dispatcher order at the host tool boundary and returns only the runner's verdict. */
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import { AGENTS } from '../lib/agents.mjs';
|
|
5
|
+
import { BRAND_STATE_DIR } from '../lib/brand-home.mjs';
|
|
6
|
+
import { sameCommand } from '../lib/dispatcher-command.mjs';
|
|
7
|
+
import { parseJsonText } from '../lib/json-file.mjs';
|
|
8
|
+
import {
|
|
9
|
+
decidePreToolUse,
|
|
10
|
+
gateOrder,
|
|
11
|
+
HANDBACK_TOOL,
|
|
12
|
+
isFinalOutput,
|
|
13
|
+
runnerOutput,
|
|
14
|
+
} from '../lib/dispatcher-gate.mjs';
|
|
15
|
+
import {
|
|
16
|
+
pruneDispatcherStates,
|
|
17
|
+
readDispatcherState,
|
|
18
|
+
updateDispatcherState,
|
|
19
|
+
} from '../lib/dispatcher-state.mjs';
|
|
20
|
+
import { hostSdkVersion, recordHandbackWitness } from '../lib/handback-witness.mjs';
|
|
21
|
+
|
|
22
|
+
function emit(decision, toolInput) {
|
|
23
|
+
const hookSpecificOutput = {
|
|
24
|
+
hookEventName: 'PreToolUse',
|
|
25
|
+
permissionDecision: decision.kind,
|
|
26
|
+
};
|
|
27
|
+
if (decision.kind === 'deny') hookSpecificOutput.permissionDecisionReason = decision.reason;
|
|
28
|
+
if (decision.kind === 'allow') {
|
|
29
|
+
hookSpecificOutput.updatedInput = { ...toolInput, message: decision.message };
|
|
30
|
+
}
|
|
31
|
+
process.stdout.write(`${JSON.stringify({ hookSpecificOutput })}\n`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function errorMessage(error) {
|
|
35
|
+
return error instanceof Error ? error.message : String(error);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function main() {
|
|
39
|
+
let payload;
|
|
40
|
+
try {
|
|
41
|
+
payload = parseJsonText('stdin', fs.readFileSync(0, 'utf8'));
|
|
42
|
+
} catch {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!payload || !Object.prototype.hasOwnProperty.call(AGENTS, payload.agent_type)
|
|
47
|
+
|| typeof payload.session_id !== 'string' || payload.session_id.length === 0
|
|
48
|
+
|| typeof payload.agent_id !== 'string' || payload.agent_id.length === 0) return;
|
|
49
|
+
|
|
50
|
+
const ids = {
|
|
51
|
+
stateDir: BRAND_STATE_DIR,
|
|
52
|
+
sessionId: payload.session_id,
|
|
53
|
+
agentId: payload.agent_id,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
if (payload.hook_event_name === 'PreToolUse') {
|
|
57
|
+
let decision;
|
|
58
|
+
let order;
|
|
59
|
+
try {
|
|
60
|
+
order = gateOrder(payload);
|
|
61
|
+
const stored = readDispatcherState(ids);
|
|
62
|
+
const state = stored?.corrupt ? {} : (stored || {});
|
|
63
|
+
decision = decidePreToolUse({ payload, order, state });
|
|
64
|
+
const toolUseId = typeof payload.tool_use_id === 'string' && payload.tool_use_id.length > 0
|
|
65
|
+
? payload.tool_use_id
|
|
66
|
+
: null;
|
|
67
|
+
await updateDispatcherState(ids, (current) => {
|
|
68
|
+
const next = {
|
|
69
|
+
...(current.corrupt ? {} : current),
|
|
70
|
+
...(decision.stateUpdate || {}),
|
|
71
|
+
};
|
|
72
|
+
if (toolUseId) {
|
|
73
|
+
const seenToolUseIds = Array.isArray(next.seenToolUseIds) ? next.seenToolUseIds : [];
|
|
74
|
+
next.seenToolUseIds = [...seenToolUseIds, toolUseId].slice(-200);
|
|
75
|
+
}
|
|
76
|
+
return next;
|
|
77
|
+
});
|
|
78
|
+
} catch (error) {
|
|
79
|
+
decision = {
|
|
80
|
+
kind: 'deny',
|
|
81
|
+
reason: `codex-bridge dispatcher gate: internal error: ${errorMessage(error)}`,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (payload.tool_name === HANDBACK_TOOL) {
|
|
86
|
+
try {
|
|
87
|
+
await recordHandbackWitness({
|
|
88
|
+
stateDir: BRAND_STATE_DIR,
|
|
89
|
+
kind: 'seen',
|
|
90
|
+
sdkVersion: hostSdkVersion(),
|
|
91
|
+
});
|
|
92
|
+
} catch {}
|
|
93
|
+
try {
|
|
94
|
+
pruneDispatcherStates({ stateDir: BRAND_STATE_DIR });
|
|
95
|
+
} catch {}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (decision.kind !== 'pass') emit(decision, payload.tool_input || {});
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if ((payload.hook_event_name === 'PostToolUse' || payload.hook_event_name === 'PostToolUseFailure')
|
|
103
|
+
&& payload.tool_name === 'Bash') {
|
|
104
|
+
try {
|
|
105
|
+
const order = gateOrder(payload);
|
|
106
|
+
if (!order.command || !sameCommand(payload.tool_input?.command, order.command)) return;
|
|
107
|
+
const result = runnerOutput(payload);
|
|
108
|
+
if (!result) return;
|
|
109
|
+
await updateDispatcherState(ids, (current) => ({
|
|
110
|
+
...(current.corrupt ? {} : current),
|
|
111
|
+
runnerOutput: result.output,
|
|
112
|
+
runnerFinal: isFinalOutput(result.output),
|
|
113
|
+
runnerExitCode: result.exitCode,
|
|
114
|
+
}));
|
|
115
|
+
} catch {}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
main().catch(() => {});
|
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
-
import os from 'node:os';
|
|
3
2
|
import path from 'node:path';
|
|
3
|
+
import { BRAND_STATE_DIR } from '../lib/brand-home.mjs';
|
|
4
4
|
import { readJsonFileSync } from '../lib/json-file.mjs';
|
|
5
5
|
|
|
6
|
-
const LOG_DIR = path.join(os.homedir(), '.claude', 'logs');
|
|
7
6
|
/**
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* Two guards share this budget (reply-guard and stop-guard). It lived in Claude Code's own
|
|
8
|
+
* `~/.claude/logs/` until Plan_62 D14 moved package state into the brand home; the move abandons
|
|
9
|
+
* budgets spent before it, which is harmless because they are keyed by agent ids that no longer run.
|
|
11
10
|
*/
|
|
12
|
-
export const BLOCKED_FILE = path.join(
|
|
11
|
+
export const BLOCKED_FILE = path.join(BRAND_STATE_DIR, 'reply-guard-tries.json');
|
|
13
12
|
export const FORM = 'form';
|
|
14
13
|
export const STATE = 'state';
|
|
15
14
|
export const MAX_FORM_BLOCKS = 3;
|
|
@@ -129,6 +129,7 @@ export function recentRuns(runsDir, options = {}) {
|
|
|
129
129
|
}
|
|
130
130
|
if (!status || typeof status !== 'object' || Array.isArray(status)) return null;
|
|
131
131
|
if (options.agent && status.agent !== options.agent) continue;
|
|
132
|
+
if (options.orderId && String(status.order_id ?? '').trim() !== options.orderId) continue;
|
|
132
133
|
const timestamp = Date.parse(status.finished_at || status.started_at || '');
|
|
133
134
|
if (!Number.isFinite(timestamp) || timestamp > now || now - timestamp > maxAgeMs) continue;
|
|
134
135
|
result.push({ dir, status, timestamp });
|
|
@@ -12,9 +12,9 @@
|
|
|
12
12
|
* silently because a diagnostic guard must never break an unrelated tool call.
|
|
13
13
|
*/
|
|
14
14
|
import fs from 'node:fs';
|
|
15
|
-
import os from 'node:os';
|
|
16
15
|
import path from 'node:path';
|
|
17
16
|
import { AGENTS } from '../lib/agents.mjs';
|
|
17
|
+
import { BRAND_STATE_DIR } from '../lib/brand-home.mjs';
|
|
18
18
|
import { readJsonFileSync } from '../lib/json-file.mjs';
|
|
19
19
|
import {
|
|
20
20
|
diagnoseInput,
|
|
@@ -30,8 +30,7 @@ import { resolveProjectRunsDir } from '../lib/runner/project-dir.mjs';
|
|
|
30
30
|
import { runsRoot } from '../lib/runner/runs-root.mjs';
|
|
31
31
|
import { parseTaskDocument } from '../lib/runner/task-file.mjs';
|
|
32
32
|
|
|
33
|
-
const
|
|
34
|
-
const LOG_DIR = path.join(HOME, '.claude', 'logs');
|
|
33
|
+
const DIAGNOSTICS_DIR = path.join(BRAND_STATE_DIR, 'diagnostics');
|
|
35
34
|
const GUARDED = new Set(Object.keys(AGENTS));
|
|
36
35
|
/**
|
|
37
36
|
* Both spellings of the subagent-launching tool, from the same list the installer builds its
|
|
@@ -60,8 +59,8 @@ try {
|
|
|
60
59
|
}
|
|
61
60
|
|
|
62
61
|
try {
|
|
63
|
-
fs.mkdirSync(
|
|
64
|
-
fs.writeFileSync(path.join(
|
|
62
|
+
fs.mkdirSync(DIAGNOSTICS_DIR, { recursive: true });
|
|
63
|
+
fs.writeFileSync(path.join(DIAGNOSTICS_DIR, 'order-gate.last.json'), `${JSON.stringify(input, null, 2)}\n`);
|
|
65
64
|
} catch {
|
|
66
65
|
// Diagnostics are a convenience, never a reason to fail the turn.
|
|
67
66
|
}
|
|
@@ -73,6 +72,22 @@ const toolInput = input.tool_input;
|
|
|
73
72
|
if (!toolInput || typeof toolInput !== 'object' || Array.isArray(toolInput)) pass();
|
|
74
73
|
if (!GUARDED.has(toolInput.subagent_type) || typeof toolInput.prompt !== 'string') pass();
|
|
75
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Plan_62 D7: with agent teams enabled, a registered dispatcher launched with `name` or `team_name`
|
|
77
|
+
* starts as a teammate — its `agent_type` becomes the teammate's name and it answers through
|
|
78
|
+
* SendMessage (live probe 2026-09-24, `docs/plans/Plan_62-probe/journal-run1.jsonl`), so the dispatcher
|
|
79
|
+
* gate never recognises it and every prohibition falls back to prompt text. Refused before it starts.
|
|
80
|
+
*/
|
|
81
|
+
const teammateField = ['name', 'team_name']
|
|
82
|
+
.find((key) => typeof toolInput[key] === 'string' && toolInput[key].trim());
|
|
83
|
+
if (teammateField) {
|
|
84
|
+
deny(
|
|
85
|
+
`Order gate denied the Agent call because it passes \`${teammateField}\`: a dispatcher launched as a ` +
|
|
86
|
+
'teammate escapes the dispatcher gate, whose agent_type becomes the teammate name. Call ' +
|
|
87
|
+
`${toolInput.subagent_type} with subagent_type only, without name or team_name.`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
76
91
|
const missing = missingInputs(toolInput.subagent_type, toolInput.prompt);
|
|
77
92
|
const unsafe = shellUnsafeInputs(toolInput.subagent_type, toolInput.prompt);
|
|
78
93
|
if (missing.length || unsafe.length) {
|