@lyupro/codex-bridge 0.5.2 → 0.5.4
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 +491 -336
- package/README.md +152 -152
- package/bin/codex-bridge.mjs +188 -174
- package/cli/doctor-format.mjs +23 -0
- package/cli/doctor-hooks.mjs +114 -0
- package/cli/doctor-installation.mjs +111 -0
- package/cli/doctor-runs.mjs +55 -0
- package/cli/doctor.mjs +35 -277
- package/cli/host-contract.mjs +135 -0
- package/cli/hosts.mjs +4 -3
- package/cli/install.mjs +31 -8
- package/cli/probe-contract.mjs +174 -0
- package/cli/settings-merge.mjs +55 -10
- package/package.json +49 -49
- package/scripts/run-tests.mjs +34 -2
- package/scripts/suite-count.mjs +65 -0
- package/src/agents/codex-build.md +265 -265
- package/src/agents/codex-review.md +219 -219
- package/src/agents/codex-scout.md +232 -232
- package/src/home/hooks/shell-write-intent.mjs +120 -0
- package/src/home/hooks/worktree-lock.mjs +109 -77
- package/src/home/hooks/worktree-witness.mjs +113 -0
- package/src/home/lib/brand-home.mjs +39 -0
- package/src/home/lib/heartbeat.mjs +4 -1
- package/src/home/lib/hook-definitions.mjs +10 -1
- package/src/home/lib/meta/reply.mjs +27 -0
- package/src/home/lib/run-config.mjs +18 -6
- package/src/home/lib/runner/codex-args.mjs +154 -0
- package/src/home/lib/runner/codex-cmd.mjs +6 -130
- package/src/home/lib/runner/launcher.mjs +18 -30
- package/src/home/lib/runner/sandbox-flags.mjs +27 -0
- package/src/home/lib/runner/worker-order.mjs +56 -0
- package/src/home/lib/write-meta.mjs +11 -3
|
@@ -1,77 +1,109 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* PreToolUse lock for file
|
|
4
|
-
*
|
|
5
|
-
* A live codex-build runner snapshots its repository before and after editing. Letting the
|
|
6
|
-
* orchestrator write during that window folds unrelated work into the runner's result, exactly
|
|
7
|
-
* as the 2026-08-05 incident did. This hook denies only a recognised file path inside a live
|
|
8
|
-
* build run's repository; every malformed or unreadable input passes so diagnostics never break
|
|
9
|
-
* unrelated host work.
|
|
10
|
-
*/
|
|
11
|
-
import fs from 'node:fs';
|
|
12
|
-
import path from 'node:path';
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
if (!
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
if (typeof
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* PreToolUse lock for file tools and plainly recognisable shell writes in the Claude Code host.
|
|
4
|
+
*
|
|
5
|
+
* A live codex-build runner snapshots its repository before and after editing. Letting the
|
|
6
|
+
* orchestrator write during that window folds unrelated work into the runner's result, exactly
|
|
7
|
+
* as the 2026-08-05 incident did. This hook denies only a recognised file path inside a live
|
|
8
|
+
* build run's repository; every malformed or unreadable input passes so diagnostics never break
|
|
9
|
+
* unrelated host work.
|
|
10
|
+
*/
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { spawnSync } from 'node:child_process';
|
|
14
|
+
import { formatSilence, heartbeatAge } from '../lib/heartbeat.mjs';
|
|
15
|
+
import { SHELL_TOOLS, WRITE_TOOLS } from '../lib/hook-definitions.mjs';
|
|
16
|
+
import { runsRoot } from '../lib/runner/runs-root.mjs';
|
|
17
|
+
import { allLiveRuns, normalizePath } from './live-runs.mjs';
|
|
18
|
+
import { shellWriteIntent } from './shell-write-intent.mjs';
|
|
19
|
+
|
|
20
|
+
const WRITE_TOOL_NAMES = new Set(WRITE_TOOLS);
|
|
21
|
+
const SHELL_TOOL_NAMES = new Set(SHELL_TOOLS);
|
|
22
|
+
const pass = () => process.exit(0);
|
|
23
|
+
|
|
24
|
+
let input;
|
|
25
|
+
try {
|
|
26
|
+
input = JSON.parse(fs.readFileSync(0, 'utf8'));
|
|
27
|
+
} catch {
|
|
28
|
+
pass();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) pass();
|
|
32
|
+
if (!WRITE_TOOL_NAMES.has(input.tool_name) && !SHELL_TOOL_NAMES.has(input.tool_name)) pass();
|
|
33
|
+
|
|
34
|
+
const toolInput = input.tool_input;
|
|
35
|
+
if (!toolInput || typeof toolInput !== 'object' || Array.isArray(toolInput)) pass();
|
|
36
|
+
let rawPaths;
|
|
37
|
+
if (WRITE_TOOL_NAMES.has(input.tool_name)) {
|
|
38
|
+
const pathField = input.tool_name === 'NotebookEdit' ? 'notebook_path' : 'file_path';
|
|
39
|
+
const rawPath = toolInput[pathField];
|
|
40
|
+
if (typeof rawPath !== 'string' || !rawPath.trim()) pass();
|
|
41
|
+
rawPaths = [rawPath];
|
|
42
|
+
} else {
|
|
43
|
+
const intent = shellWriteIntent(toolInput.command);
|
|
44
|
+
if (!intent.writes || !intent.paths.length) pass();
|
|
45
|
+
rawPaths = intent.paths;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let liveRuns;
|
|
49
|
+
try {
|
|
50
|
+
liveRuns = allLiveRuns(runsRoot());
|
|
51
|
+
} catch {
|
|
52
|
+
pass();
|
|
53
|
+
}
|
|
54
|
+
let match;
|
|
55
|
+
for (const rawPath of rawPaths) {
|
|
56
|
+
let targetPath;
|
|
57
|
+
try {
|
|
58
|
+
if (path.isAbsolute(rawPath)) targetPath = rawPath;
|
|
59
|
+
else if (typeof input.cwd === 'string' && input.cwd.trim()) targetPath = path.resolve(input.cwd, rawPath);
|
|
60
|
+
else continue;
|
|
61
|
+
} catch {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const target = normalizePath(targetPath);
|
|
65
|
+
if (!target) continue;
|
|
66
|
+
const owner = liveRuns.find(({ status }) => {
|
|
67
|
+
if (status.agent !== 'codex-build') return false;
|
|
68
|
+
const repository = normalizePath(status.repo);
|
|
69
|
+
return Boolean(repository) && (target === repository || target.startsWith(`${repository}/`));
|
|
70
|
+
});
|
|
71
|
+
if (owner) {
|
|
72
|
+
match = { owner, targetPath };
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (!match) pass();
|
|
77
|
+
|
|
78
|
+
const { status, dir } = match.owner;
|
|
79
|
+
// A run is only ever failed for what `git status --porcelain` reports, and an ignored path never
|
|
80
|
+
// appears there — the witness that grades a run does not see it either. Meanwhile the working
|
|
81
|
+
// order for a live run is to touch exactly those files: plans and checklists live in ignored
|
|
82
|
+
// folders. On 2026-08-23 this guard refused four such edits in five minutes, which is worse than
|
|
83
|
+
// a miss: a miss has the witness behind it, a false refusal has nothing and reads as a broken
|
|
84
|
+
// tool. Only a proven `ignored` passes — anything else (not a repository, git absent, any other
|
|
85
|
+
// error) keeps the refusal, because "no proof it is ignored" is not "proof it is harmless".
|
|
86
|
+
const repository = normalizePath(status.repo);
|
|
87
|
+
const target = normalizePath(match.targetPath);
|
|
88
|
+
const gitMetadata = target === `${repository}/.git` || target.startsWith(`${repository}/.git/`);
|
|
89
|
+
if (!gitMetadata) {
|
|
90
|
+
const ignored = spawnSync('git', ['-C', status.repo, 'check-ignore', '--quiet', '--', match.targetPath], {
|
|
91
|
+
stdio: 'ignore',
|
|
92
|
+
});
|
|
93
|
+
if (ignored.status === 0) pass();
|
|
94
|
+
}
|
|
95
|
+
process.stdout.write(JSON.stringify({
|
|
96
|
+
hookSpecificOutput: {
|
|
97
|
+
hookEventName: 'PreToolUse',
|
|
98
|
+
permissionDecision: 'deny',
|
|
99
|
+
permissionDecisionReason: `Worktree lock denied ${input.tool_name}: ${match.targetPath} is inside repository `
|
|
100
|
+
+ `${status.repo}, held by live run folder ${dir} (agent ${status.agent}, slug ${status.slug}). `
|
|
101
|
+
// "Working, last progress X ago" rather than "silent for X": this hook only ever denies a
|
|
102
|
+
// run whose heartbeat is still fresh — a stale one is not live here and the edit passes. The
|
|
103
|
+
// first live probe printed "silent for 10 seconds" next to a stop command on a perfectly
|
|
104
|
+
// healthy run, which reads as "it hung, kill it" and would cost the operator 20 minutes of
|
|
105
|
+
// work they meant to keep.
|
|
106
|
+
+ `It is working; last progress ${formatSilence(heartbeatAge(dir))} ago. `
|
|
107
|
+
+ `To take the repository back before it finishes: codex-bridge stop ${path.basename(dir)}.`,
|
|
108
|
+
},
|
|
109
|
+
}));
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* PostToolUse witness for shell writes into a live codex-build repository.
|
|
4
|
+
*
|
|
5
|
+
* A shell can write through Python, redirection, or any other command while bypassing the
|
|
6
|
+
* file-tool lock. That is how the orchestrator changed CHANGELOG.md during the 2026-08-16
|
|
7
|
+
* split-guard run and made an honest build appear out of scope. This hook compares the live
|
|
8
|
+
* run's recorded git state with the repository after every shell tool and directs the
|
|
9
|
+
* orchestrator to undo any change outside the run's declared scope.
|
|
10
|
+
*/
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { spawnSync } from 'node:child_process';
|
|
14
|
+
import { SHELL_TOOLS } from '../lib/hook-definitions.mjs';
|
|
15
|
+
import { parseJsonText } from '../lib/json-file.mjs';
|
|
16
|
+
import { globToRegExp } from '../lib/meta/paths.mjs';
|
|
17
|
+
import { runsRoot } from '../lib/runner/runs-root.mjs';
|
|
18
|
+
import { allLiveRuns, normalizePath } from './live-runs.mjs';
|
|
19
|
+
|
|
20
|
+
const SHELL_TOOL_NAMES = new Set(SHELL_TOOLS);
|
|
21
|
+
const pass = () => process.exit(0);
|
|
22
|
+
|
|
23
|
+
function readRequired(file) {
|
|
24
|
+
try {
|
|
25
|
+
return fs.readFileSync(file, 'utf8');
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function statusMap(text) {
|
|
32
|
+
if (typeof text !== 'string') return null;
|
|
33
|
+
const result = new Map();
|
|
34
|
+
for (const line of text.split(/\r?\n/).filter(Boolean)) {
|
|
35
|
+
if (!/^.. /.test(line)) return null;
|
|
36
|
+
const rawPath = line.slice(3);
|
|
37
|
+
const file = rawPath.includes(' -> ') ? rawPath.slice(rawPath.lastIndexOf(' -> ') + 4) : rawPath;
|
|
38
|
+
if (!file.trim()) return null;
|
|
39
|
+
result.set(file, line.slice(0, 2));
|
|
40
|
+
}
|
|
41
|
+
return result;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let input;
|
|
45
|
+
try {
|
|
46
|
+
input = parseJsonText('stdin', fs.readFileSync(0, 'utf8'));
|
|
47
|
+
} catch {
|
|
48
|
+
pass();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) pass();
|
|
52
|
+
if (!SHELL_TOOL_NAMES.has(input.tool_name)) pass();
|
|
53
|
+
const cwd = normalizePath(input.cwd);
|
|
54
|
+
if (!cwd) pass();
|
|
55
|
+
|
|
56
|
+
let liveRuns;
|
|
57
|
+
try {
|
|
58
|
+
liveRuns = allLiveRuns(runsRoot());
|
|
59
|
+
} catch {
|
|
60
|
+
pass();
|
|
61
|
+
}
|
|
62
|
+
const owner = liveRuns.find(({ status }) => {
|
|
63
|
+
if (status.agent !== 'codex-build') return false;
|
|
64
|
+
const repository = normalizePath(status.repo);
|
|
65
|
+
return Boolean(repository) && (cwd === repository || cwd.startsWith(`${repository}/`));
|
|
66
|
+
});
|
|
67
|
+
if (!owner) pass();
|
|
68
|
+
|
|
69
|
+
const { dir, status } = owner;
|
|
70
|
+
const beforeText = readRequired(path.join(dir, 'git-before.txt'));
|
|
71
|
+
const scopeText = readRequired(path.join(dir, 'scope.txt'));
|
|
72
|
+
if (beforeText === null || scopeText === null) pass();
|
|
73
|
+
|
|
74
|
+
const before = statusMap(beforeText);
|
|
75
|
+
let allowed;
|
|
76
|
+
try {
|
|
77
|
+
const patterns = scopeText.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
78
|
+
if (!patterns.length) pass();
|
|
79
|
+
allowed = patterns.map(globToRegExp);
|
|
80
|
+
} catch {
|
|
81
|
+
pass();
|
|
82
|
+
}
|
|
83
|
+
if (!before) pass();
|
|
84
|
+
|
|
85
|
+
let currentResult;
|
|
86
|
+
try {
|
|
87
|
+
currentResult = spawnSync('git', ['-C', status.repo, 'status', '--porcelain'], {
|
|
88
|
+
encoding: 'utf8',
|
|
89
|
+
});
|
|
90
|
+
} catch {
|
|
91
|
+
pass();
|
|
92
|
+
}
|
|
93
|
+
if (currentResult.error || currentResult.status !== 0 || typeof currentResult.stdout !== 'string') pass();
|
|
94
|
+
const current = statusMap(currentResult.stdout);
|
|
95
|
+
if (!current) pass();
|
|
96
|
+
|
|
97
|
+
const changed = [];
|
|
98
|
+
for (const [file, state] of current) if (before.get(file) !== state) changed.push(file);
|
|
99
|
+
for (const file of before.keys()) if (!current.has(file)) changed.push(file);
|
|
100
|
+
const outside = [...new Set(changed)].filter((file) => !allowed.some((pattern) => pattern.test(file)));
|
|
101
|
+
if (!outside.length) pass();
|
|
102
|
+
|
|
103
|
+
const folder = path.basename(dir);
|
|
104
|
+
const paths = outside.join(', ');
|
|
105
|
+
process.stdout.write(JSON.stringify({
|
|
106
|
+
hookSpecificOutput: {
|
|
107
|
+
hookEventName: 'PostToolUse',
|
|
108
|
+
additionalContext: `WORKTREE WITNESS — act now: the shell changed paths outside the scope of `
|
|
109
|
+
+ `live run folder ${dir} (agent ${status.agent}, slug ${status.slug}): ${paths}. `
|
|
110
|
+
+ `Revert those paths now, or run codex-bridge stop ${folder} to take the repository back `
|
|
111
|
+
+ `before making further changes.`,
|
|
112
|
+
},
|
|
113
|
+
}));
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolves where the package's host-side files live: the brand home and the files seeded into it.
|
|
3
|
+
*
|
|
4
|
+
* One resolver, because the rule existed twice and the two copies disagreed. Until 2026-08-26 the
|
|
5
|
+
* runtime built the path to config.json from its own module directory, so `codex-bridge run` read
|
|
6
|
+
* the seed shipped inside the package while the operator edited `~/.lyupro/.codex-bridge/config.json`.
|
|
7
|
+
* A model and an effort pinned there on 5 August never reached a single run — every run silently
|
|
8
|
+
* used defaults, `env.json` recorded `"models": {}`, three releases shipped with it, and `doctor`
|
|
9
|
+
* kept confirming the setting because the CLI resolved the path correctly.
|
|
10
|
+
*
|
|
11
|
+
* The environment and the home directory are injectable so no test needs the operator's real home;
|
|
12
|
+
* `source` is reported because a path chosen by an override and one chosen by default must be
|
|
13
|
+
* distinguishable wherever the setting is shown.
|
|
14
|
+
*/
|
|
15
|
+
import os from 'node:os';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
|
|
18
|
+
export function resolveBrandHome({ homedir = os.homedir(), env = process.env } = {}) {
|
|
19
|
+
const overridden = Boolean(env.CODEX_BRIDGE_HOME);
|
|
20
|
+
const root = path.resolve(
|
|
21
|
+
overridden ? env.CODEX_BRIDGE_HOME : path.join(homedir, '.lyupro', '.codex-bridge'),
|
|
22
|
+
);
|
|
23
|
+
return {
|
|
24
|
+
root,
|
|
25
|
+
source: overridden ? 'CODEX_BRIDGE_HOME' : 'default',
|
|
26
|
+
configPath: path.join(root, 'config.json'),
|
|
27
|
+
conventionsPath: path.join(root, 'conventions.md'),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolved once per process, deliberately: a run reads its configuration in the launcher and must
|
|
33
|
+
* not see the answer change underneath it. Code that needs the answer for another home — a test, an
|
|
34
|
+
* installer targeting `--host` — calls resolveBrandHome() with that home instead of editing the
|
|
35
|
+
* environment after import, which would not be seen here.
|
|
36
|
+
*/
|
|
37
|
+
export const BRAND_HOME = resolveBrandHome();
|
|
38
|
+
export const BRAND_CONFIG_PATH = BRAND_HOME.configPath;
|
|
39
|
+
export const BRAND_CONVENTIONS_PATH = BRAND_HOME.conventionsPath;
|
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
* The one definition of run progress: a heartbeat file in the run folder, its age, and the
|
|
3
3
|
* stale threshold. On 2026-08-06 run 2026-08-06_204007_build left a live worker waiting on
|
|
4
4
|
* Codex's grandchild; a worker timer alone would have kept that dead run looking alive, so
|
|
5
|
-
* the timer stops at exit.
|
|
5
|
+
* the timer stops at exit. The file's modification time is the run's best-effort sign of recent
|
|
6
|
+
* progress. A failed stamp is swallowed so this hint cannot prevent the run's normal verdict and
|
|
7
|
+
* artifacts. For an old record, a missing file is a legacy state that keeps its lock until its pid
|
|
8
|
+
* dies.
|
|
6
9
|
*/
|
|
7
10
|
import fs from 'node:fs';
|
|
8
11
|
import path from 'node:path';
|
|
@@ -46,6 +46,14 @@ export const STOP_TOOLS = Object.freeze(['TaskStop']);
|
|
|
46
46
|
/** A PreToolUse matcher is a regular expression over tool names, so the list remains authoritative. */
|
|
47
47
|
export const STOP_TOOL_MATCHER = STOP_TOOLS.join('|');
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* The lock listens to file tools and shells through one registration, not two. A second definition
|
|
51
|
+
* naming the same file lands twice in the installation record, and that record forbids duplicate
|
|
52
|
+
* files — 33 tests failed on it the day the shell side was added. One hook, one entry, one matcher
|
|
53
|
+
* wide enough for both.
|
|
54
|
+
*/
|
|
55
|
+
export const LOCKED_TOOL_MATCHER = [...WRITE_TOOLS, ...SHELL_TOOLS].join('|');
|
|
56
|
+
|
|
49
57
|
/**
|
|
50
58
|
* Stable CLI names live beside each event and file because the Plan_19 drift incident left
|
|
51
59
|
* registration, diagnostics, and the guard itself carrying different hook identities. The
|
|
@@ -54,7 +62,8 @@ export const STOP_TOOL_MATCHER = STOP_TOOLS.join('|');
|
|
|
54
62
|
export const HOOK_DEFINITIONS = Object.freeze([
|
|
55
63
|
Object.freeze({ name: 'reply-guard', event: 'SubagentStop', matcher: '*', file: 'reply-guard.mjs' }),
|
|
56
64
|
Object.freeze({ name: 'order-gate', event: 'PreToolUse', matcher: SUBAGENT_TOOL_MATCHER, file: 'order-gate.mjs' }),
|
|
57
|
-
Object.freeze({ name: 'worktree-lock', event: 'PreToolUse', matcher:
|
|
65
|
+
Object.freeze({ name: 'worktree-lock', event: 'PreToolUse', matcher: LOCKED_TOOL_MATCHER, file: 'worktree-lock.mjs' }),
|
|
58
66
|
Object.freeze({ name: 'prune-guard', event: 'PreToolUse', matcher: SHELL_TOOL_MATCHER, file: 'prune-guard.mjs' }),
|
|
67
|
+
Object.freeze({ name: 'worktree-witness', event: 'PostToolUse', matcher: SHELL_TOOL_MATCHER, file: 'worktree-witness.mjs' }),
|
|
59
68
|
Object.freeze({ name: 'stop-guard', event: 'PreToolUse', matcher: STOP_TOOL_MATCHER, file: 'stop-guard.mjs' }),
|
|
60
69
|
]);
|
|
@@ -139,6 +139,33 @@ function reviewReply(ctx) {
|
|
|
139
139
|
];
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Which worker actually ran, printed next to the log link.
|
|
144
|
+
*
|
|
145
|
+
* A dispatcher reads these rows and nothing else, so a run on the wrong model was invisible here
|
|
146
|
+
* for three releases: the configured profile never reached the command line, and no reply said
|
|
147
|
+
* which model had answered (2026-08-26). The row names the depth's origin too — a `fallback`
|
|
148
|
+
* depth on a mode configured for `max` is the same silence one level down.
|
|
149
|
+
*/
|
|
150
|
+
export function profileRow(meta) {
|
|
151
|
+
const profile = meta?.profile;
|
|
152
|
+
if (!profile?.effort) return null;
|
|
153
|
+
const model = profile.model || 'codex default';
|
|
154
|
+
return `Model: ${model} at ${profile.effort} effort (${profile.effort_source})`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Inserted before the row carrying the log link, or appended when a reply has none. The link is
|
|
159
|
+
* matched anywhere in the row, not at its start: scout and review put it after the report path,
|
|
160
|
+
* and the profile belongs beside it in every reply rather than only in two of them.
|
|
161
|
+
*/
|
|
162
|
+
export function withProfileRow(rows, meta) {
|
|
163
|
+
const row = profileRow(meta);
|
|
164
|
+
if (!row) return rows;
|
|
165
|
+
const at = rows.findIndex((r) => r.includes('Log: '));
|
|
166
|
+
return at === -1 ? [...rows, row] : [...rows.slice(0, at), row, ...rows.slice(at)];
|
|
167
|
+
}
|
|
168
|
+
|
|
142
169
|
export function failReply(ctx, meta) {
|
|
143
170
|
const retention = retentionReply(ctx.runDir);
|
|
144
171
|
return [
|
|
@@ -19,10 +19,18 @@
|
|
|
19
19
|
import fs from 'node:fs';
|
|
20
20
|
import path from 'node:path';
|
|
21
21
|
import { fileURLToPath } from 'node:url';
|
|
22
|
+
import { BRAND_CONFIG_PATH, BRAND_HOME } from './brand-home.mjs';
|
|
22
23
|
import { readJsonFileSync } from './json-file.mjs';
|
|
23
24
|
|
|
24
|
-
const
|
|
25
|
-
|
|
25
|
+
export const CONFIG_PATH = BRAND_CONFIG_PATH;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Every print of the state says which file was read AND why that one. The defect this replaced was
|
|
29
|
+
* invisible precisely because the path was never shown beside its origin: two files named
|
|
30
|
+
* config.json, and no way to tell from the output which of them the run had just used.
|
|
31
|
+
*/
|
|
32
|
+
const ORIGIN =
|
|
33
|
+
BRAND_HOME.source === 'CODEX_BRIDGE_HOME' ? 'from CODEX_BRIDGE_HOME' : 'default location';
|
|
26
34
|
|
|
27
35
|
const BUDGET_KEY = 'budgets';
|
|
28
36
|
const BUDGET_MODES = ['scout', 'build', 'review'];
|
|
@@ -73,7 +81,11 @@ const MODEL_KEYS = ['scout', 'build', 'review'];
|
|
|
73
81
|
* fallback depth the dispatcher happens to pass.
|
|
74
82
|
*/
|
|
75
83
|
const PROFILE_KEYS = ['model', 'effort'];
|
|
76
|
-
|
|
84
|
+
// Exactly the values the service enumerates, and no more. `minimal` sat here until 2026-08-26,
|
|
85
|
+
// passed validation and was rejected by the model itself after the run had started
|
|
86
|
+
// ("Unsupported value: 'minimal' is not supported with the … model"), so an order died four
|
|
87
|
+
// seconds in on quota already spent. A validator that admits a known-dead value is worse than none.
|
|
88
|
+
export const ALLOWED_EFFORTS = Object.freeze(['none', 'low', 'medium', 'high', 'xhigh', 'max']);
|
|
77
89
|
/**
|
|
78
90
|
* The language a run answers in. Left to the model it followed the task, the surrounding docs or
|
|
79
91
|
* its own default: an English order came back in Russian, and artifacts of one project ended up in
|
|
@@ -308,13 +320,13 @@ function main(argv) {
|
|
|
308
320
|
|
|
309
321
|
if (!key) {
|
|
310
322
|
const config = readRunConfig();
|
|
311
|
-
console.log([...state(config), `File: ${CONFIG_PATH}`].join('\n'));
|
|
323
|
+
console.log([...state(config), `File: ${CONFIG_PATH} (${ORIGIN})`].join('\n'));
|
|
312
324
|
return 0;
|
|
313
325
|
}
|
|
314
326
|
|
|
315
327
|
if (key === 'reset') {
|
|
316
328
|
writeRunConfig({ ...DEFAULTS });
|
|
317
|
-
console.log([...state(DEFAULTS), `Reset to defaults · ${CONFIG_PATH}`].join('\n'));
|
|
329
|
+
console.log([...state(DEFAULTS), `Reset to defaults · ${CONFIG_PATH} (${ORIGIN})`].join('\n'));
|
|
318
330
|
return 0;
|
|
319
331
|
}
|
|
320
332
|
|
|
@@ -334,7 +346,7 @@ function main(argv) {
|
|
|
334
346
|
|
|
335
347
|
const config = { ...readRunConfig(), [key]: value === 'on' };
|
|
336
348
|
writeRunConfig(config);
|
|
337
|
-
console.log([...state(config), `File: ${CONFIG_PATH}`].join('\n'));
|
|
349
|
+
console.log([...state(config), `File: ${CONFIG_PATH} (${ORIGIN})`].join('\n'));
|
|
338
350
|
return 0;
|
|
339
351
|
}
|
|
340
352
|
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a run asks Codex to do: the model, the reasoning depth and the flag set per agent.
|
|
3
|
+
*
|
|
4
|
+
* Split out of codex-cmd.mjs when that file outgrew the size limit; the seam is deliberate, not
|
|
5
|
+
* arithmetic — starting a process and deciding what it is asked to do are different jobs, and
|
|
6
|
+
* only this half needs to know the run's configuration.
|
|
7
|
+
*
|
|
8
|
+
* The flag sets are the ones the three agent files carried before, verbatim. Sandboxes especially
|
|
9
|
+
* must not drift: codex-scout and codex-review are read-only through an explicit --sandbox
|
|
10
|
+
* read-only, and codex-build needs workspace-write and must NOT get --ignore-user-config. What
|
|
11
|
+
* --ignore-user-config buys the two read-only agents is isolation from the operator's config,
|
|
12
|
+
* never the sandbox itself — reading it as the read-only guarantee is how the 2026-08-23 breakage
|
|
13
|
+
* stayed misdiagnosed as "read-only is broken" (see sandbox-flags.mjs for what actually stops a
|
|
14
|
+
* run from starting a process on Windows).
|
|
15
|
+
*/
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { CLEAN_ENV, RUN_ENV } from './run-env.mjs';
|
|
18
|
+
import { platformSandboxArgs } from './sandbox-flags.mjs';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Which model runs a mode, and how deep it reasons.
|
|
22
|
+
*
|
|
23
|
+
* Three sources, in this order: what the dispatcher asked for this one task, the mode's
|
|
24
|
+
* configured profile, and finally a depth to fall back on. The order is the whole point —
|
|
25
|
+
* pinning a model without its depth leaves every run at the fallback, which is how a mode
|
|
26
|
+
* configured for maximum reasoning would quietly keep working at the shallowest setting.
|
|
27
|
+
*/
|
|
28
|
+
const FALLBACK_EFFORT = 'medium';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A run is one worker, not a team it recruits for itself.
|
|
32
|
+
*
|
|
33
|
+
* Codex can spawn subagents when a prompt asks it to, and their edits land in the same
|
|
34
|
+
* worktree — so they arrive in the snapshot as the run's own work, outside the scope check
|
|
35
|
+
* that grades it, on quota nobody budgeted. The prompts say not to; this is why they cannot.
|
|
36
|
+
*/
|
|
37
|
+
const NO_SUBAGENTS = ['-c', 'agents.enabled=false'];
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Which configured mode an agent is. One table, because the launcher needs the same answer to
|
|
41
|
+
* pick a run's time budget: a second copy of this mapping is a second place to forget a mode.
|
|
42
|
+
*/
|
|
43
|
+
export const runMode = (agent) =>
|
|
44
|
+
({
|
|
45
|
+
'codex-scout': 'scout',
|
|
46
|
+
'codex-build': 'build',
|
|
47
|
+
'codex-review': 'review',
|
|
48
|
+
})[agent];
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Which model and depth this run gets, and — as part of the same answer — where each came from.
|
|
52
|
+
*
|
|
53
|
+
* The provenance is not decoration. A configured profile failed to reach any run for three
|
|
54
|
+
* releases (2026-08-26) and nothing said so: the reply named no model, meta.json recorded none,
|
|
55
|
+
* and a depth silently served by the fallback is indistinguishable from one the operator chose.
|
|
56
|
+
* `model_source` is `config` or `codex default`; `effort_source` is `request`, `config` or
|
|
57
|
+
* `fallback`, in the order the values are consulted above.
|
|
58
|
+
*/
|
|
59
|
+
export function runProfile(opts) {
|
|
60
|
+
const mode = runMode(opts.agent);
|
|
61
|
+
// opts.models is how tests state a configuration; a real run reads the one loaded for it.
|
|
62
|
+
const configured = (opts.models || RUN_ENV?.models || {})[mode] || {};
|
|
63
|
+
return {
|
|
64
|
+
model: configured.model || '',
|
|
65
|
+
model_source: configured.model ? 'config' : 'codex default',
|
|
66
|
+
effort: opts.effort || configured.effort || FALLBACK_EFFORT,
|
|
67
|
+
effort_source: opts.effort ? 'request' : configured.effort ? 'config' : 'fallback',
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function codexArgs(opts, runDir, isGitRepo) {
|
|
72
|
+
const schema = path.join(runDir, 'schema.json');
|
|
73
|
+
const profile = runProfile(opts);
|
|
74
|
+
const effort = `model_reasoning_effort=${profile.effort}`;
|
|
75
|
+
const modelArgs = profile.model ? ['-m', profile.model] : [];
|
|
76
|
+
if (opts.agent === 'codex-scout') {
|
|
77
|
+
return [
|
|
78
|
+
'exec',
|
|
79
|
+
'--json',
|
|
80
|
+
...CLEAN_ENV,
|
|
81
|
+
'--ignore-user-config',
|
|
82
|
+
'-c',
|
|
83
|
+
effort,
|
|
84
|
+
...NO_SUBAGENTS,
|
|
85
|
+
...platformSandboxArgs(),
|
|
86
|
+
...modelArgs,
|
|
87
|
+
'--sandbox',
|
|
88
|
+
'read-only',
|
|
89
|
+
'--skip-git-repo-check',
|
|
90
|
+
'-C',
|
|
91
|
+
opts.repo,
|
|
92
|
+
'--output-schema',
|
|
93
|
+
schema,
|
|
94
|
+
'-o',
|
|
95
|
+
path.join(runDir, 'result.json'),
|
|
96
|
+
'-',
|
|
97
|
+
];
|
|
98
|
+
}
|
|
99
|
+
if (opts.agent === 'codex-build') {
|
|
100
|
+
// --ignore-user-config is deliberately absent: with it Codex forces read-only and
|
|
101
|
+
// every edit is rejected ("writing is blocked by read-only sandbox").
|
|
102
|
+
return [
|
|
103
|
+
'exec',
|
|
104
|
+
'--json',
|
|
105
|
+
...CLEAN_ENV,
|
|
106
|
+
'-c',
|
|
107
|
+
effort,
|
|
108
|
+
...NO_SUBAGENTS,
|
|
109
|
+
...platformSandboxArgs(),
|
|
110
|
+
...modelArgs,
|
|
111
|
+
'--sandbox',
|
|
112
|
+
'workspace-write',
|
|
113
|
+
'-C',
|
|
114
|
+
opts.repo,
|
|
115
|
+
...(isGitRepo ? [] : ['--skip-git-repo-check']),
|
|
116
|
+
'--output-schema',
|
|
117
|
+
schema,
|
|
118
|
+
'-o',
|
|
119
|
+
path.join(runDir, 'result.json'),
|
|
120
|
+
'-',
|
|
121
|
+
];
|
|
122
|
+
}
|
|
123
|
+
// codex-review deliberately does NOT use the `review` subcommand. Two of its traits
|
|
124
|
+
// break the contract this design rests on: it rejects a scope flag together with a
|
|
125
|
+
// prompt ("the argument '--uncommitted' cannot be used with '[PROMPT]'"), so task.md
|
|
126
|
+
// was silently discarded and the review rules never arrived; and it ignores
|
|
127
|
+
// --output-schema, writing prose into -o, so the old JSON parsing of review.json could
|
|
128
|
+
// only ever fail — which is what pushed a dispatcher into reviewing the diff itself.
|
|
129
|
+
//
|
|
130
|
+
// Plain `codex exec` honours the schema (proven by codex-scout), so the review comes
|
|
131
|
+
// back as machine-checkable JSON. Scope lives in the prompt as a file list plus the
|
|
132
|
+
// exact diff command computed by reviewScope(). Read-only through
|
|
133
|
+
// --ignore-user-config, same as scout: review never needs write access.
|
|
134
|
+
return [
|
|
135
|
+
'exec',
|
|
136
|
+
'--json',
|
|
137
|
+
...CLEAN_ENV,
|
|
138
|
+
'--ignore-user-config',
|
|
139
|
+
'-c',
|
|
140
|
+
effort,
|
|
141
|
+
...NO_SUBAGENTS,
|
|
142
|
+
...platformSandboxArgs(),
|
|
143
|
+
...modelArgs,
|
|
144
|
+
'--sandbox',
|
|
145
|
+
'read-only',
|
|
146
|
+
'-C',
|
|
147
|
+
opts.repo,
|
|
148
|
+
'--output-schema',
|
|
149
|
+
schema,
|
|
150
|
+
'-o',
|
|
151
|
+
path.join(runDir, 'review.json'),
|
|
152
|
+
'-',
|
|
153
|
+
];
|
|
154
|
+
}
|