@lyupro/codex-bridge 0.6.10 → 0.6.12

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.
Files changed (60) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/README.md +16 -4
  3. package/cli/active-hosts.mjs +44 -0
  4. package/cli/command-registry.mjs +18 -2
  5. package/cli/dispatcher-model-check.mjs +61 -0
  6. package/cli/doctor-host-side.mjs +117 -110
  7. package/cli/doctor-installation.mjs +111 -111
  8. package/cli/doctor.mjs +19 -4
  9. package/cli/handback-witness-check.mjs +30 -20
  10. package/cli/hosts.mjs +2 -10
  11. package/cli/install-owners.mjs +1 -4
  12. package/cli/install-record.mjs +7 -1
  13. package/cli/inventory-confirm.mjs +75 -0
  14. package/cli/inventory-transition.mjs +4 -2
  15. package/cli/manifest.mjs +6 -6
  16. package/package.json +2 -2
  17. package/src/agents/codex-advisor.md +25 -119
  18. package/src/agents/codex-build.md +15 -133
  19. package/src/agents/codex-review.md +10 -109
  20. package/src/agents/codex-scout.md +11 -130
  21. package/src/home/hooks/dispatcher-gate.mjs +14 -9
  22. package/src/home/hooks/live-runs.mjs +0 -41
  23. package/src/home/hooks/order-gate.mjs +59 -40
  24. package/src/home/hooks/reply-guard.mjs +52 -65
  25. package/src/home/hooks/reply-identity.mjs +64 -0
  26. package/src/home/hooks/reply-verdicts.mjs +31 -27
  27. package/src/home/hooks/reply-witness.mjs +48 -0
  28. package/src/home/lib/claude-layout.mjs +14 -0
  29. package/src/home/lib/dispatcher-call.mjs +171 -0
  30. package/src/home/lib/dispatcher-command.mjs +18 -56
  31. package/src/home/lib/dispatcher-gate.mjs +4 -0
  32. package/src/home/lib/dispatcher-model.mjs +112 -0
  33. package/src/home/lib/dispatcher-order.mjs +21 -22
  34. package/src/home/lib/dispatcher-pin.mjs +60 -0
  35. package/src/home/lib/dispatcher-protocol.mjs +91 -0
  36. package/src/home/lib/dispatcher-stop.mjs +11 -2
  37. package/src/home/lib/handback-witness.mjs +137 -48
  38. package/src/home/lib/home-registry.mjs +7 -0
  39. package/src/home/lib/install-owner-roots.mjs +55 -0
  40. package/src/home/lib/meta/paths.mjs +1 -1
  41. package/src/home/lib/meta/reply.mjs +2 -1
  42. package/src/home/lib/model-family.mjs +38 -0
  43. package/src/home/lib/observation-ledger.mjs +145 -0
  44. package/src/home/lib/order-schema.mjs +204 -0
  45. package/src/home/lib/order-values.mjs +46 -0
  46. package/src/home/lib/runner/args.mjs +140 -231
  47. package/src/home/lib/runner/attach.mjs +3 -3
  48. package/src/home/lib/runner/continuation.mjs +174 -192
  49. package/src/home/lib/runner/launcher.mjs +9 -9
  50. package/src/home/lib/runner/order-options.mjs +30 -0
  51. package/src/home/lib/runner/order-owner.mjs +2 -1
  52. package/src/home/lib/runner/pass-gate.mjs +11 -9
  53. package/src/home/lib/runner/preflight.mjs +4 -3
  54. package/src/home/lib/runner/scope-check.mjs +11 -10
  55. package/src/home/lib/runner/task-input.mjs +23 -10
  56. package/src/home/lib/subagent-model-env.mjs +44 -0
  57. package/src/home/lib/task-header.mjs +158 -127
  58. package/src/home/lib/untyped-stop.mjs +30 -0
  59. package/src/home/lib/required-inputs.mjs +0 -301
  60. /package/{cli → src/home/lib}/frontmatter.mjs +0 -0
@@ -1,231 +1,140 @@
1
- /**
2
- * Reads the command line of a run and refuses it before anything is spent.
3
- *
4
- * Every check here happens before the run folder exists and before a single token of someone
5
- * else's quota is touched, and every refusal is an exit code rather than a message: a
6
- * dispatcher branches on it.
7
- */
8
- import { EXIT } from './exit-codes.mjs';
9
- import fs from 'node:fs';
10
- import path from 'node:path';
11
- import { AGENTS } from '../write-meta.mjs';
12
- import { isAbsoluteTaskFilePath, requiredInputsFor } from '../required-inputs.mjs';
13
- import { firstShellUnsafeSequence } from '../shell-unsafe.mjs';
14
- import { parseTaskDocument } from './task-file.mjs';
15
- import { parseTaskHeader, taskHeaderRefusal } from '../task-header.mjs';
16
-
17
- export class RunnerUsageError extends Error {
18
- // 2 says the order itself is wrong and has to be rewritten. A refusal about the state of the
19
- // host or the worktree passes 1: the order was fine, nothing about it needs changing, and
20
- // Plan_58 moved two such refusals here from a code path that already answered 1.
21
- constructor(message, exitCode = EXIT.USAGE) {
22
- super(message);
23
- this.exitCode = exitCode;
24
- }
25
- }
26
-
27
- export function die(message, exitCode = EXIT.USAGE) {
28
- console.error(`run-codex: ${message}`);
29
- throw new RunnerUsageError(message, exitCode);
30
- }
31
-
32
- export function readTaskDocument(opts) {
33
- const stdinText = process.stdin.isTTY ? '' : fs.readFileSync(0, 'utf8');
34
- if (opts['task-file'] !== undefined) {
35
- if (stdinText.trim()) {
36
- die('task text was supplied through both stdin and --task-file; choose exactly one channel');
37
- }
38
- const taskFile = opts['task-file'];
39
- // The 2026-08-15 incident resolved a relative order against the repository cwd and ran an
40
- // unrelated task.md. Refuse the supplied value instead of silently selecting another file.
41
- if (!isAbsoluteTaskFilePath(taskFile)) {
42
- die(`--task-file must be an absolute path; got ${JSON.stringify(taskFile)}`);
43
- }
44
- let fileText;
45
- try {
46
- fileText = fs.readFileSync(taskFile, 'utf8');
47
- } catch (err) {
48
- die(`task file from --task-file could not be read: ${err.message}`);
49
- }
50
- if (!fileText.trim()) die(`task file from --task-file is empty: ${taskFile}`);
51
- // Plan_75 D5, 2026-10-03 20:42: validate raw metadata before sections can hide a grant.
52
- const parsed = parseTaskHeader(fileText);
53
- const refusal = taskHeaderRefusal(parsed);
54
- if (refusal) die(`${taskFile}: ${refusal}`);
55
- if (!parsed.body.trim()) die(`task file from --task-file is empty: ${taskFile}`);
56
- let document;
57
- try {
58
- document = parseTaskDocument(parsed.body);
59
- } catch (err) {
60
- die(`${taskFile}: ${err.message}`);
61
- }
62
- if (document.questions.length && opts.questions?.length) {
63
- die('questions were supplied through both --question and the task file; choose exactly one channel');
64
- }
65
- if (document.verify !== undefined && opts.verify !== undefined) {
66
- die('verification command was supplied through both --verify and the task file; choose exactly one channel');
67
- }
68
- return { ...document, header: parsed };
69
- }
70
- if (!stdinText.trim()) die('task text on stdin is empty');
71
- const parsed = parseTaskHeader(stdinText);
72
- const refusal = taskHeaderRefusal(parsed);
73
- if (refusal) die(refusal);
74
- if (!parsed.body.trim()) die('task text on stdin is empty');
75
- try {
76
- return { ...parseTaskDocument(parsed.body), header: parsed };
77
- } catch (err) {
78
- die(err.message);
79
- }
80
- }
81
-
82
- function requiredInput(agentType, label) {
83
- return requiredInputsFor(agentType).find((entry) => entry.label === label);
84
- }
85
-
86
- // Flags that carry no value. A value is accepted only in its explicit yes/no spellings;
87
- // anything else stops the run. The permissive reading this replaces — "not 0/false/no means
88
- // yes" — turned the prompt's own placeholder text (`--continue "<only if the orchestrator provided
89
- // continue>"`) into a silent opt-in, and a real run started on someone else's quota. A flag
90
- // whose whole point is that a human decided it must never be switched on by a leftover
91
- // template.
92
- const BOOLEAN_FLAGS = new Set(['continue', 'no-wait']);
93
- const REPEATABLE_FLAGS = new Set(['question']);
94
- // Plan_42 keeps free text in the task file because these command-line values otherwise disable
95
- // the host's standing permission before the runner can spend quota.
96
- const SHELL_CHECKED_FLAGS = Object.freeze([
97
- 'order-id',
98
- 'slug',
99
- 'scope',
100
- 'scope-new',
101
- 'repo',
102
- 'agent',
103
- 'phase',
104
- 'effort',
105
- 'changeset',
106
- 'task-file',
107
- ]);
108
- const BOOLEAN_YES = /^(1|true|yes)$/i;
109
- const BOOLEAN_NO = /^(0|false|no)$/i;
110
-
111
- export function parseArgs(argv) {
112
- const opts = {};
113
- for (let i = 0; i < argv.length; i += 1) {
114
- const key = argv[i];
115
- if (!key.startsWith('--')) die(`unexpected argument: ${key}`);
116
- const name = key.slice(2);
117
- // Plan_56 D10 requires --changeset: a stale reviewer flag must not silently select uncommitted work.
118
- if (name === 'mode') die('unknown flag: --mode; use --changeset instead');
119
- const value = argv[i + 1];
120
- if (BOOLEAN_FLAGS.has(name)) {
121
- if (value !== undefined && !value.startsWith('--')) {
122
- if (!BOOLEAN_YES.test(value) && !BOOLEAN_NO.test(value)) {
123
- die(
124
- `--${name} takes no value, or one of 1/true/yes/0/false/no; got ${JSON.stringify(value)}. ` +
125
- 'A placeholder left in from the prompt template is not consent.',
126
- );
127
- }
128
- opts[name] = BOOLEAN_YES.test(value);
129
- i += 1;
130
- } else {
131
- opts[name] = true;
132
- }
133
- continue;
134
- }
135
- // A flag name where a value belongs means the value was left out: `--question --continue`
136
- // otherwise records `--continue` as the question and passes every later check.
137
- if (value === undefined || value.startsWith('--')) die(`missing value for ${key}`);
138
- if (REPEATABLE_FLAGS.has(name)) {
139
- if (!opts.questions) opts.questions = [];
140
- opts.questions.push(value);
141
- i += 1;
142
- continue;
143
- }
144
- opts[name] = value;
145
- i += 1;
146
- }
147
- for (const name of SHELL_CHECKED_FLAGS) {
148
- if (opts[name] === undefined) continue;
149
- const sequence = firstShellUnsafeSequence(opts[name]);
150
- if (sequence !== null) {
151
- die(
152
- `--${name} contains forbidden shell sequence ${JSON.stringify(sequence)}; ` +
153
- 'put free text in the task file and pass only a short command-line value.',
154
- );
155
- }
156
- }
157
- if (!opts.agent) die('--agent is required');
158
- if (!AGENTS[opts.agent]) die(`unknown --agent ${opts.agent}`);
159
- opts.orderId = String(opts['order-id'] ?? '').trim();
160
- opts.noWait = Boolean(opts['no-wait']);
161
- if (opts.noWait && opts.continue) {
162
- die('--no-wait cannot be combined with --continue: checking an existing run must never authorize a new one.');
163
- }
164
- if (!opts.orderId) {
165
- const orderInput = requiredInput(opts.agent, 'order id');
166
- die(
167
- `--order-id is required: ${orderInput.source} supplies the ${orderInput.label}; the runner will not invent one. ` +
168
- 'A repeat of the same order should come back with --continue. If this appeared right after a package update, ' +
169
- 'the installed dispatcher prompts are stale and need npm run dev:install (local dev) or codex-bridge update. ' +
170
- `Example: --order-id "${orderInput.example}". Action: get the value from ${orderInput.source}, pass it as --order-id, and retry.`,
171
- );
172
- }
173
- if (opts.agent === 'codex-scout') {
174
- // The requirement itself moved to the launcher, which is the first point that has seen both
175
- // sources: questions now arrive either as flags or as a section of the task file, and this
176
- // check runs before either file or stdin has been read. Only the shape of a supplied flag is
177
- // still judged here.
178
- if (opts.questions?.some((question) => !String(question).trim())) {
179
- die('--question must not be empty for codex-scout; no quota was spent');
180
- }
181
- }
182
- // Left unset when not given, rather than defaulted here: the role's configured profile is
183
- // what fills the gap, and a default applied this early would always win over it.
184
- // Plan_56 step 3: support belongs to the live catalogue on write and Codex at run start.
185
- if (opts.effort !== undefined && (!opts.effort || /\s/.test(opts.effort))) {
186
- die(`--effort must be a non-empty single word with no whitespace; got ${JSON.stringify(opts.effort)}`);
187
- }
188
- opts.repo = path.resolve(opts.repo || process.cwd());
189
- const slugSource = opts.slug ? '--slug' : '--order-id';
190
- opts.slug = (opts.slug || opts.orderId).replace(/[^A-Za-z0-9._-]+/g, '-');
191
- // Plan_29 incident: the generic `build` slug made an honest order inherit a days-old chain;
192
- // reject a value with no alphanumeric anchor instead of creating an empty or dot-only folder.
193
- if (!/[A-Za-z0-9]/.test(opts.slug)) {
194
- die(
195
- `${slugSource} produces an unusable run folder name after sanitization: ` +
196
- `${JSON.stringify(opts.slug)} must contain a letter or digit.`,
197
- );
198
- }
199
- opts.changeset = opts.changeset || 'uncommitted';
200
- // A writing run without a declared scope is how `!Plans/*.md` got edited by a run that was
201
- // told in prose not to touch them: prose does not bind, a file list does. Required rather
202
- // than defaulted to "everything", and checked here — before the folder exists and before a
203
- // single token of someone else's quota is spent.
204
- const declaredScopePatterns = String(opts.scope || '')
205
- .split(',')
206
- .map((p) => p.trim())
207
- .filter(Boolean);
208
- opts.scopeNewPatterns = String(opts['scope-new'] || '')
209
- .split(',')
210
- .map((p) => p.trim())
211
- .filter(Boolean);
212
- // Plan_27 keeps ordinary patterns strict: only paths named explicitly as new may be absent.
213
- opts.scopePatterns = [...declaredScopePatterns, ...opts.scopeNewPatterns];
214
- // Only the writing agent creates files, so only its scope may name one that does not exist yet.
215
- // Accepting the flag elsewhere would hand scout and review a way to waive the check for nothing.
216
- if (opts.agent !== 'codex-build' && opts.scopeNewPatterns.length) {
217
- die(
218
- `--scope-new is only for codex-build: ${opts.agent} does not create files. ` +
219
- 'Action: drop --scope-new and pass every path through --scope.',
220
- );
221
- }
222
- if (opts.agent === 'codex-build' && !declaredScopePatterns.length) {
223
- const scopeInput = requiredInput(opts.agent, 'scope');
224
- die(
225
- `--scope is required for codex-build: ${scopeInput.source} supplies the ${scopeInput.label}. ` +
226
- `${scopeInput.explanation} Example: --scope "${scopeInput.example}". ` +
227
- `Action: get the value from ${scopeInput.source}, pass it as --scope, and retry.`,
228
- );
229
- }
230
- return opts;
231
- }
1
+ /**
2
+ * Reads the command line of a run and refuses it before anything is spent.
3
+ *
4
+ * Every check here happens before the run folder exists and before a single token of someone
5
+ * else's quota is touched, and every refusal is an exit code rather than a message: a
6
+ * dispatcher branches on it.
7
+ */
8
+ import { EXIT } from './exit-codes.mjs';
9
+ import fs from 'node:fs';
10
+ import { AGENTS } from '../write-meta.mjs';
11
+ import { isAbsoluteTaskFilePath } from '../order-values.mjs';
12
+ import { firstShellUnsafeSequence } from '../shell-unsafe.mjs';
13
+ import { parseTaskDocument } from './task-file.mjs';
14
+ import { parseTaskHeader, taskHeaderRefusal } from '../task-header.mjs';
15
+ import { renderOrderHeaderHelp } from '../order-schema.mjs';
16
+
17
+ export class RunnerUsageError extends Error {
18
+ // 2 says the order itself is wrong and has to be rewritten. A refusal about the state of the
19
+ // host or the worktree passes 1: the order was fine, nothing about it needs changing, and
20
+ // Plan_58 moved two such refusals here from a code path that already answered 1.
21
+ constructor(message, exitCode = EXIT.USAGE) {
22
+ super(message);
23
+ this.exitCode = exitCode;
24
+ }
25
+ }
26
+
27
+ export function die(message, exitCode = EXIT.USAGE) {
28
+ console.error(`run-codex: ${message}`);
29
+ throw new RunnerUsageError(message, exitCode);
30
+ }
31
+
32
+ export function readTaskDocument(opts) {
33
+ const refuse = (message) => die([message, renderOrderHeaderHelp(opts.agent)].filter(Boolean).join('\n'));
34
+ const stdinText = process.stdin.isTTY ? '' : fs.readFileSync(0, 'utf8');
35
+ if (opts['task-file'] !== undefined) {
36
+ if (stdinText.trim()) {
37
+ refuse('task text was supplied through both stdin and --task-file; choose exactly one channel');
38
+ }
39
+ const taskFile = opts['task-file'];
40
+ // The 2026-08-15 incident resolved a relative order against the repository cwd and ran an
41
+ // unrelated task.md. Refuse the supplied value instead of silently selecting another file.
42
+ if (!isAbsoluteTaskFilePath(taskFile)) {
43
+ refuse(`--task-file must be an absolute path; got ${JSON.stringify(taskFile)}`);
44
+ }
45
+ let fileText;
46
+ try {
47
+ fileText = fs.readFileSync(taskFile, 'utf8');
48
+ } catch (err) {
49
+ refuse(`task file from --task-file could not be read: ${err.message}`);
50
+ }
51
+ if (!fileText.trim()) refuse(`task file from --task-file is empty: ${taskFile}`);
52
+ // Plan_75 D5, 2026-10-03 20:42: validate raw metadata before sections can hide a grant.
53
+ const parsed = parseTaskHeader(fileText);
54
+ const refusal = taskHeaderRefusal(parsed);
55
+ if (refusal) refuse(`${taskFile}: ${refusal}`);
56
+ if (!parsed.body.trim()) refuse(`task file from --task-file is empty: ${taskFile}`);
57
+ let document;
58
+ try {
59
+ document = parseTaskDocument(parsed.body);
60
+ } catch (err) {
61
+ refuse(`${taskFile}: ${err.message}`);
62
+ }
63
+ return { ...document, header: parsed };
64
+ }
65
+ if (!stdinText.trim()) refuse('task text on stdin is empty');
66
+ const parsed = parseTaskHeader(stdinText);
67
+ const refusal = taskHeaderRefusal(parsed);
68
+ if (refusal) refuse(refusal);
69
+ if (!parsed.body.trim()) refuse('task text on stdin is empty');
70
+ try {
71
+ return { ...parseTaskDocument(parsed.body), header: parsed };
72
+ } catch (err) {
73
+ refuse(err.message);
74
+ }
75
+ }
76
+
77
+ // Flags that carry no value. A value is accepted only in its explicit yes/no spellings;
78
+ // anything else stops the run. The permissive reading this replaces — "not 0/false/no means
79
+ // yes" — turned the prompt's own placeholder text (`--continue "<only if the orchestrator provided
80
+ // continue>"`) into a silent opt-in, and a real run started on someone else's quota. A flag
81
+ // whose whole point is that a human decided it must never be switched on by a leftover
82
+ // template.
83
+ const BOOLEAN_FLAGS = new Set(['no-wait']);
84
+ // Plan_42 keeps free text in the task file because these command-line values otherwise disable
85
+ // the host's standing permission before the runner can spend quota.
86
+ const SHELL_CHECKED_FLAGS = Object.freeze([
87
+ 'agent',
88
+ 'task-file',
89
+ ]);
90
+ const BOOLEAN_YES = /^(1|true|yes)$/i;
91
+ const BOOLEAN_NO = /^(0|false|no)$/i;
92
+
93
+ export function parseArgs(argv) {
94
+ const opts = {};
95
+ for (let i = 0; i < argv.length; i += 1) {
96
+ const key = argv[i];
97
+ if (!key.startsWith('--')) die(`unexpected argument: ${key}`);
98
+ const name = key.slice(2);
99
+ if (!['agent', 'task-file', 'no-wait'].includes(name)) {
100
+ const agent = opts.agent ?? argv[argv.indexOf('--agent') + 1];
101
+ const help = Object.hasOwn(AGENTS, agent) ? renderOrderHeaderHelp(agent) : '';
102
+ die(`unknown flag --${name}: codex-bridge run takes only --agent, --task-file and --no-wait; ` +
103
+ 'the order belongs in the task-file header' + (help ? `\n${help}` : ''));
104
+ }
105
+ const value = argv[i + 1];
106
+ if (BOOLEAN_FLAGS.has(name)) {
107
+ if (value !== undefined && !value.startsWith('--')) {
108
+ if (!BOOLEAN_YES.test(value) && !BOOLEAN_NO.test(value)) {
109
+ die(
110
+ `--${name} takes no value, or one of 1/true/yes/0/false/no; got ${JSON.stringify(value)}. ` +
111
+ 'A placeholder left in from the prompt template is not consent.',
112
+ );
113
+ }
114
+ opts[name] = BOOLEAN_YES.test(value);
115
+ i += 1;
116
+ } else {
117
+ opts[name] = true;
118
+ }
119
+ continue;
120
+ }
121
+ // A flag name where a value belongs means the value was left out.
122
+ if (value === undefined || value.startsWith('--')) die(`missing value for ${key}`);
123
+ opts[name] = value;
124
+ i += 1;
125
+ }
126
+ for (const name of SHELL_CHECKED_FLAGS) {
127
+ if (opts[name] === undefined) continue;
128
+ const sequence = firstShellUnsafeSequence(opts[name]);
129
+ if (sequence !== null) {
130
+ die(
131
+ `--${name} contains forbidden shell sequence ${JSON.stringify(sequence)}; ` +
132
+ 'put free text in the task file and pass only a short command-line value.',
133
+ );
134
+ }
135
+ }
136
+ if (!opts.agent) die('--agent is required');
137
+ if (!AGENTS[opts.agent]) die(`unknown --agent ${opts.agent}`);
138
+ opts.noWait = Boolean(opts['no-wait']);
139
+ return opts;
140
+ }
@@ -89,13 +89,13 @@ export async function waitForReply(runDir, workerPid, status) {
89
89
  * What counts as "not finished yet" is the absence of reply.txt, not the absence of meta.json.
90
90
  * The artifact order is meta.json first, reply.txt last, so between the two there is a moment
91
91
  * where the verdict exists and the run is not closed — judged by meta.json, a repeat arriving in
92
- * that window would be refused and sent to the --continue gate instead of being answered. The
92
+ * that window would be refused and sent to the continuation gate instead of being answered. The
93
93
  * promise being kept here is that repeating the command is always safe, and a promise with a
94
94
  * window in it is not one. markAbandoned() closes dead runs with FAIL before the gate, so this
95
95
  * path only covers a live run that has not recorded a verdict yet.
96
96
  *
97
97
  * A repeat that arrives after the verdict is answered from disk rather than refused, for the same
98
- * reason. `--continue` may attach to a run created from its grant: the 2026-09-23 dispatcher
98
+ * reason. A `continue:` grant may attach to a run created from it: the 2026-09-23 dispatcher
99
99
  * incident showed that repeating the identical continuation command must return to that run.
100
100
  */
101
101
  export async function attach({
@@ -121,7 +121,7 @@ export async function attach({
121
121
  }
122
122
 
123
123
  // The run an order is currently about is its newest one, and the chain arrives oldest first.
124
- // Reading it from the end is the whole point: `--continue` adds a second run under the same
124
+ // Reading it from the end is the whole point: a continuation adds a second run under the same
125
125
  // order, and while it was in flight a repeat used to be answered by the first run's reply.txt —
126
126
  // a stale verdict presented as this pass's answer. Found by the Plan_11-2 checklist, 2026-08-04.
127
127
  let candidate = null;