@lyupro/codex-bridge 0.6.6 → 0.6.8
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 +79 -0
- package/README.md +7 -5
- package/bin/codex-bridge.mjs +186 -178
- package/cli/dispatcher-contract-record.mjs +51 -0
- package/cli/dispatcher-contract.mjs +90 -0
- package/cli/doctor-hooks.mjs +104 -114
- package/cli/doctor-installation.mjs +111 -111
- package/cli/doctor-runs.mjs +55 -55
- package/cli/doctor.mjs +53 -5
- package/cli/handback-witness-check.mjs +31 -0
- package/cli/hook-targets.mjs +14 -6
- package/cli/hook.mjs +32 -23
- package/cli/host-contract.mjs +145 -135
- package/cli/install.mjs +23 -17
- package/cli/launcher-probe.mjs +38 -0
- package/cli/probe-contract.mjs +55 -70
- package/cli/probe-rig.mjs +53 -0
- package/cli/probe-target.mjs +33 -0
- package/cli/run-launcher.mjs +17 -0
- package/cli/session-hosts.mjs +51 -0
- package/cli/settings-merge.mjs +42 -35
- package/cli/uninstall.mjs +16 -3
- package/cli/update.mjs +45 -16
- package/package.json +4 -4
- package/scripts/run-tests.mjs +7 -0
- 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 +132 -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 +95 -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 +70 -0
- package/src/home/lib/hook-definitions.mjs +10 -0
- package/src/home/lib/hook-entry.mjs +20 -0
- package/src/home/lib/host-observations.mjs +57 -0
- package/src/home/lib/host-version.mjs +27 -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/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,18 @@ 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, the host `
|
|
46
|
+
+ 'observations, dispatcher contract verdicts, and hook diagnostics that hold the full hook input). Delete them by '
|
|
47
|
+
+ 'hand for a complete removal.';
|
|
48
|
+
}
|
|
49
|
+
|
|
36
50
|
function displayFile(file) {
|
|
37
51
|
return `${file.root}/${file.path}`;
|
|
38
52
|
}
|
|
@@ -62,8 +76,7 @@ async function uninstallInRun({ host, dryRun = false } = {}) {
|
|
|
62
76
|
const permissionResult = await removePermissionRules(host.settingsPath, { dryRun });
|
|
63
77
|
const permissionLine = permissionOutput(host, permissionResult.removed, dryRun);
|
|
64
78
|
const record = await readInstallRecord(host);
|
|
65
|
-
const preservation =
|
|
66
|
-
+ `configuration in ${host.brandConfigPath} are preserved.`;
|
|
79
|
+
const preservation = preservationText(host);
|
|
67
80
|
if (!record) {
|
|
68
81
|
return { exitCode: 1, output: `${permissionLine}\ncodex-bridge is not installed.\n${preservation}` };
|
|
69
82
|
}
|
|
@@ -92,7 +105,7 @@ async function uninstallInRun({ host, dryRun = false } = {}) {
|
|
|
92
105
|
}
|
|
93
106
|
for (const hook of record.hooks) {
|
|
94
107
|
const definition = definitionForRecordedHook(hook);
|
|
95
|
-
lines.push(`Would remove the ${hook.event} hook for matcher ${definition.matcher}.`);
|
|
108
|
+
lines.push(`Would remove the ${hook.event} hook ${definition.name} for matcher ${definition.matcher}.`);
|
|
96
109
|
}
|
|
97
110
|
lines.push('Would remove the installation record from the brand root.');
|
|
98
111
|
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.8",
|
|
4
|
+
"description": "Claude Code-to-Codex delegation with 7 guards, 1667 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"
|
package/scripts/run-tests.mjs
CHANGED
|
@@ -21,6 +21,13 @@ import { spawnSync } from 'node:child_process';
|
|
|
21
21
|
import { ISOLATED_ROOTS } from './isolated-roots.mjs';
|
|
22
22
|
import { readmeText, suiteCountMismatch } from './suite-count.mjs';
|
|
23
23
|
|
|
24
|
+
// One pattern only: on 2026-09-25 three files passed as three arguments ran just the first, and a
|
|
25
|
+
// verification reported green for two files it never touched. Refuse before any root is created.
|
|
26
|
+
if (process.argv.length > 3) {
|
|
27
|
+
console.error('run-tests: takes ONE pattern; pass a glob such as "tests/cli/{doctor,install}.test.mjs"');
|
|
28
|
+
process.exit(2);
|
|
29
|
+
}
|
|
30
|
+
|
|
24
31
|
const created = ISOLATED_ROOTS.map((name) => [
|
|
25
32
|
name,
|
|
26
33
|
fs.mkdtempSync(path.join(os.tmpdir(), `codex-bridge-test-${name.toLowerCase()}-`)),
|
|
@@ -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,132 @@
|
|
|
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 { recordHandbackWitness, witnessHostVersion } from '../lib/handback-witness.mjs';
|
|
21
|
+
import { observeSessionHost } from '../lib/host-observations.mjs';
|
|
22
|
+
|
|
23
|
+
function emit(decision, toolInput) {
|
|
24
|
+
const hookSpecificOutput = {
|
|
25
|
+
hookEventName: 'PreToolUse',
|
|
26
|
+
permissionDecision: decision.kind,
|
|
27
|
+
};
|
|
28
|
+
if (decision.kind === 'deny') hookSpecificOutput.permissionDecisionReason = decision.reason;
|
|
29
|
+
if (decision.kind === 'allow') {
|
|
30
|
+
hookSpecificOutput.updatedInput = { ...toolInput, message: decision.message };
|
|
31
|
+
}
|
|
32
|
+
process.stdout.write(`${JSON.stringify({ hookSpecificOutput })}\n`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function errorMessage(error) {
|
|
36
|
+
return error instanceof Error ? error.message : String(error);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function main() {
|
|
40
|
+
let payload;
|
|
41
|
+
try {
|
|
42
|
+
payload = parseJsonText('stdin', fs.readFileSync(0, 'utf8'));
|
|
43
|
+
} catch {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (payload?.hook_event_name === 'PreToolUse'
|
|
48
|
+
&& typeof payload.session_id === 'string' && payload.session_id.length > 0
|
|
49
|
+
&& typeof payload.transcript_path === 'string' && payload.transcript_path.length > 0) {
|
|
50
|
+
try {
|
|
51
|
+
await observeSessionHost({
|
|
52
|
+
stateDir: BRAND_STATE_DIR,
|
|
53
|
+
sessionId: payload.session_id,
|
|
54
|
+
transcriptPath: payload.transcript_path,
|
|
55
|
+
});
|
|
56
|
+
} catch {}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!payload || !Object.prototype.hasOwnProperty.call(AGENTS, payload.agent_type)
|
|
60
|
+
|| typeof payload.session_id !== 'string' || payload.session_id.length === 0
|
|
61
|
+
|| typeof payload.agent_id !== 'string' || payload.agent_id.length === 0) return;
|
|
62
|
+
|
|
63
|
+
const ids = {
|
|
64
|
+
stateDir: BRAND_STATE_DIR,
|
|
65
|
+
sessionId: payload.session_id,
|
|
66
|
+
agentId: payload.agent_id,
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
if (payload.hook_event_name === 'PreToolUse') {
|
|
70
|
+
let decision;
|
|
71
|
+
let order;
|
|
72
|
+
try {
|
|
73
|
+
order = gateOrder(payload);
|
|
74
|
+
const stored = readDispatcherState(ids);
|
|
75
|
+
const state = stored?.corrupt ? {} : (stored || {});
|
|
76
|
+
decision = decidePreToolUse({ payload, order, state });
|
|
77
|
+
const toolUseId = typeof payload.tool_use_id === 'string' && payload.tool_use_id.length > 0
|
|
78
|
+
? payload.tool_use_id
|
|
79
|
+
: null;
|
|
80
|
+
await updateDispatcherState(ids, (current) => {
|
|
81
|
+
const next = {
|
|
82
|
+
...(current.corrupt ? {} : current),
|
|
83
|
+
...(decision.stateUpdate || {}),
|
|
84
|
+
};
|
|
85
|
+
if (toolUseId) {
|
|
86
|
+
const seenToolUseIds = Array.isArray(next.seenToolUseIds) ? next.seenToolUseIds : [];
|
|
87
|
+
next.seenToolUseIds = [...seenToolUseIds, toolUseId].slice(-200);
|
|
88
|
+
}
|
|
89
|
+
return next;
|
|
90
|
+
});
|
|
91
|
+
} catch (error) {
|
|
92
|
+
decision = {
|
|
93
|
+
kind: 'deny',
|
|
94
|
+
reason: `codex-bridge dispatcher gate: internal error: ${errorMessage(error)}`,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (payload.tool_name === HANDBACK_TOOL) {
|
|
99
|
+
try {
|
|
100
|
+
await recordHandbackWitness({
|
|
101
|
+
stateDir: BRAND_STATE_DIR,
|
|
102
|
+
kind: 'seen',
|
|
103
|
+
hostVersion: await witnessHostVersion(payload),
|
|
104
|
+
});
|
|
105
|
+
} catch {}
|
|
106
|
+
try {
|
|
107
|
+
pruneDispatcherStates({ stateDir: BRAND_STATE_DIR });
|
|
108
|
+
} catch {}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (decision.kind !== 'pass') emit(decision, payload.tool_input || {});
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if ((payload.hook_event_name === 'PostToolUse' || payload.hook_event_name === 'PostToolUseFailure')
|
|
116
|
+
&& payload.tool_name === 'Bash') {
|
|
117
|
+
try {
|
|
118
|
+
const order = gateOrder(payload);
|
|
119
|
+
if (!order.command || !sameCommand(payload.tool_input?.command, order.command)) return;
|
|
120
|
+
const result = runnerOutput(payload);
|
|
121
|
+
if (!result) return;
|
|
122
|
+
await updateDispatcherState(ids, (current) => ({
|
|
123
|
+
...(current.corrupt ? {} : current),
|
|
124
|
+
runnerOutput: result.output,
|
|
125
|
+
runnerFinal: isFinalOutput(result.output),
|
|
126
|
+
runnerExitCode: result.exitCode,
|
|
127
|
+
}));
|
|
128
|
+
} catch {}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
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) {
|