@lyupro/codex-bridge 0.6.2 → 0.6.5

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 (43) hide show
  1. package/CHANGELOG.md +146 -0
  2. package/README.md +3 -3
  3. package/cli/runs-inventory.mjs +5 -3
  4. package/cli/stop.mjs +2 -2
  5. package/cli/unlock.mjs +2 -2
  6. package/package.json +2 -2
  7. package/src/agents/codex-advisor.md +187 -0
  8. package/src/agents/codex-build.md +4 -0
  9. package/src/commands/env.md +5 -4
  10. package/src/home/hooks/live-runs.mjs +19 -21
  11. package/src/home/hooks/reply-guard.mjs +5 -2
  12. package/src/home/hooks/shell-write-intent.mjs +320 -154
  13. package/src/home/hooks/worktree-lock.mjs +9 -2
  14. package/src/home/hooks/worktree-witness.mjs +21 -40
  15. package/src/home/lib/agents.mjs +3 -0
  16. package/src/home/lib/budgets.mjs +64 -0
  17. package/src/home/lib/config-validate.mjs +3 -39
  18. package/src/home/lib/meta/advice-status.mjs +61 -0
  19. package/src/home/lib/meta/advice-verdict.mjs +226 -0
  20. package/src/home/lib/meta/advisor-task.mjs +69 -0
  21. package/src/home/lib/meta/events.mjs +4 -0
  22. package/src/home/lib/meta/launch-rows.mjs +45 -0
  23. package/src/home/lib/meta/reply.mjs +42 -34
  24. package/src/home/lib/meta/run-liveness.mjs +64 -0
  25. package/src/home/lib/meta/run-state.mjs +28 -34
  26. package/src/home/lib/meta/verdict.mjs +18 -0
  27. package/src/home/lib/required-inputs.mjs +25 -4
  28. package/src/home/lib/run-codex.mjs +1 -0
  29. package/src/home/lib/run-config.mjs +5 -1
  30. package/src/home/lib/runner/args.mjs +8 -4
  31. package/src/home/lib/runner/attach.mjs +5 -13
  32. package/src/home/lib/runner/codex-args.mjs +14 -5
  33. package/src/home/lib/runner/codex-cmd.mjs +105 -19
  34. package/src/home/lib/runner/git-state.mjs +6 -1
  35. package/src/home/lib/runner/launcher.mjs +36 -30
  36. package/src/home/lib/runner/preflight.mjs +109 -0
  37. package/src/home/lib/runner/prompts.mjs +39 -0
  38. package/src/home/lib/runner/sandbox-probe.mjs +109 -0
  39. package/src/home/lib/runner/schemas.mjs +137 -1
  40. package/src/home/lib/runner/scope-check.mjs +31 -3
  41. package/src/home/lib/runner/worker-order.mjs +4 -2
  42. package/src/home/lib/runner/worker.mjs +2 -2
  43. package/src/home/lib/write-meta.mjs +18 -1
@@ -11,7 +11,6 @@ import fs from 'node:fs';
11
11
  import path from 'node:path';
12
12
  import { spawn, spawnSync } from 'node:child_process';
13
13
  import { createHeartbeat, HEARTBEAT_INTERVAL_MS } from '../heartbeat.mjs';
14
- import { writeFailure } from '../write-meta.mjs';
15
14
  import { MAX_LOG } from './git-state.mjs';
16
15
 
17
16
  // Thirty seconds, not a fraction of one: after `exit` the pipe still holds whatever Codex wrote
@@ -20,6 +19,8 @@ import { MAX_LOG } from './git-state.mjs';
20
19
  // left no event". The grace exists to bound a grandchild holding stdio open forever (2026-08-06,
21
20
  // run 2026-08-06_204007_build waited 25 minutes), and thirty seconds bounds that just as well.
22
21
  const STDIO_DRAIN_GRACE_MS = 30_000;
22
+ // Plan_57 D28: a stalled taskkill must not block probe or paid-run deadline settlement forever.
23
+ const TASKKILL_TIMEOUT_MS = 10_000;
23
24
 
24
25
  /**
25
26
  * An argument cmd.exe cannot be handed safely, or undefined. `%` is fatal because cmd
@@ -30,6 +31,17 @@ const STDIO_DRAIN_GRACE_MS = 30_000;
30
31
  export const unsafeForCmd = (args) =>
31
32
  ['codex', ...args].find((a) => a.includes('%') || a.includes('"'));
32
33
 
34
+ // Plan_57: preflight and paid runs must use the same Windows quoting and npm shim path.
35
+ export function codexSpawnSpec(args, platform = process.platform) {
36
+ if (platform !== 'win32') return { command: 'codex', args, options: {} };
37
+ const cmdline = ['codex', ...args].map((a) => (/[\s&|<>^]/.test(a) ? `"${a}"` : a)).join(' ');
38
+ return {
39
+ command: process.env.ComSpec || 'cmd.exe',
40
+ args: ['/d', '/s', '/c', `"${cmdline}"`],
41
+ options: { windowsVerbatimArguments: true, windowsHide: true },
42
+ };
43
+ }
44
+
33
45
  /**
34
46
  * Stop the process that owns the Codex invocation. On Windows that process is cmd.exe, so
35
47
  * child.kill() would reap only the shell and leave Codex spending quota as its grandchild —
@@ -44,10 +56,91 @@ export function stopCodex(child, onWindows = process.platform === 'win32') {
44
56
  const killed = spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], {
45
57
  stdio: 'ignore',
46
58
  windowsHide: true,
59
+ timeout: TASKKILL_TIMEOUT_MS,
47
60
  });
48
61
  if (killed.error || killed.status !== 0) child.kill();
49
62
  }
50
63
 
64
+ // Plan_57 D28: keep spawnSync's 1 MiB default (stdout head, stderr tail); overflow is inconclusive, never dead.
65
+ const CAPTURE_LIMIT_BYTES = 1024 * 1024;
66
+ const CAPTURE_CLOSE_GRACE_MS = 2_000;
67
+ const CAPTURE_STOP_GRACE_MS = 5_000;
68
+
69
+ /**
70
+ * Plan_57 D24: async capture lets the deadline stop the whole Windows process tree,
71
+ * preventing the 2026-07-31 orphan that spawnSync's shell-only timeout leaves behind.
72
+ * Bounded pipe draining also prevents the inherited-stdio hang of 2026-08-06.
73
+ */
74
+ export function spawnCaptured(command, args, options) {
75
+ const { timeout, encoding, shell, ...rest } = options ?? {};
76
+ if (typeof timeout !== 'number' || !Number.isFinite(timeout) || timeout <= 0) {
77
+ throw new TypeError('spawnCaptured requires a finite timeout greater than zero');
78
+ }
79
+ return new Promise((resolve) => {
80
+ const child = spawn(command, args, { ...rest, stdio: ['ignore', 'pipe', 'pipe'] });
81
+ let stdout = Buffer.alloc(0);
82
+ let stderr = Buffer.alloc(0);
83
+ let captureError = null;
84
+ let settled = false;
85
+ let stopped = false;
86
+ let deadlineTimer;
87
+ let closeTimer;
88
+ let stopTimer;
89
+ const finish = (status, signal, error, destroyStreams = false) => {
90
+ if (settled) return;
91
+ settled = true;
92
+ clearTimeout(deadlineTimer);
93
+ clearTimeout(closeTimer);
94
+ clearTimeout(stopTimer);
95
+ if (destroyStreams) {
96
+ child.stdout.destroy();
97
+ child.stderr.destroy();
98
+ child.unref();
99
+ }
100
+ resolve({ status, signal, error, stdout: stdout.toString('utf8'), stderr: stderr.toString('utf8') });
101
+ };
102
+ const stop = () => {
103
+ if (stopped) return;
104
+ stopped = true;
105
+ stopCodex(child);
106
+ };
107
+ const onPipeError = (error) => {
108
+ if (settled) return;
109
+ stop();
110
+ finish(null, null, error, true);
111
+ };
112
+ child.stdout.on('error', onPipeError);
113
+ child.stderr.on('error', onPipeError);
114
+ child.stdout.on('data', (chunk) => {
115
+ const incoming = Buffer.from(chunk);
116
+ const room = CAPTURE_LIMIT_BYTES - stdout.length;
117
+ if (incoming.length > room && !captureError) {
118
+ captureError = Object.assign(new Error(`stdout exceeded ${CAPTURE_LIMIT_BYTES} bytes`), { code: 'ENOBUFS' });
119
+ }
120
+ if (room > 0) stdout = Buffer.concat([stdout, incoming.subarray(0, room)]);
121
+ });
122
+ child.stderr.on('data', (chunk) => {
123
+ stderr = Buffer.concat([stderr, Buffer.from(chunk)]);
124
+ if (stderr.length > CAPTURE_LIMIT_BYTES) stderr = stderr.subarray(-CAPTURE_LIMIT_BYTES);
125
+ });
126
+ child.on('error', (error) => finish(null, null, error, true));
127
+ child.on('close', (code, signal) => finish(code, signal, captureError));
128
+ child.on('exit', (code, signal) => {
129
+ if (settled) return;
130
+ clearTimeout(deadlineTimer);
131
+ clearTimeout(stopTimer);
132
+ closeTimer = setTimeout(() => finish(code, signal, captureError, true), CAPTURE_CLOSE_GRACE_MS);
133
+ });
134
+ deadlineTimer = setTimeout(() => {
135
+ captureError = Object.assign(new Error(`timed out after ${timeout} ms`), { code: 'ETIMEDOUT' });
136
+ stop();
137
+ if (!settled) {
138
+ stopTimer = setTimeout(() => finish(null, null, captureError, true), CAPTURE_STOP_GRACE_MS);
139
+ }
140
+ }, timeout);
141
+ });
142
+ }
143
+
51
144
  /**
52
145
  * Windows npm ships `codex` as codex.cmd, and Node refuses to spawn .cmd without a
53
146
  * shell. One command line through cmd.exe keeps a single code path; every argument is
@@ -65,7 +158,7 @@ export function stopCodex(child, onWindows = process.platform === 'win32') {
65
158
  export function runCodex(args, taskText, eventsPath, budgetMinutes, graceMs = STDIO_DRAIN_GRACE_MS) {
66
159
  const onWindows = process.platform === 'win32';
67
160
  const deadlineMs =
68
- typeof budgetMinutes === 'number' && Number.isFinite(budgetMinutes) && budgetMinutes > 0
161
+ Number.isFinite(budgetMinutes) && budgetMinutes > 0
69
162
  ? budgetMinutes * 60 * 1000
70
163
  : null;
71
164
  let exited = false;
@@ -129,21 +222,17 @@ export function runCodex(args, taskText, eventsPath, budgetMinutes, graceMs = ST
129
222
  return { append, finish };
130
223
  })();
131
224
 
132
- let child;
133
225
  if (onWindows) {
134
226
  // Thrown rather than died: in the worker a bare exit would leave the run open, while a
135
227
  // throw goes through the crash handler and closes it with meta.json like any failure.
136
228
  const bad = unsafeForCmd(args);
137
229
  if (bad) throw new Error(`argument unsafe for cmd.exe (contains % or "): ${bad}`);
138
- const cmdline = ['codex', ...args].map((a) => (/[\s&|<>^]/.test(a) ? `"${a}"` : a)).join(' ');
139
- child = spawn(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', `"${cmdline}"`], {
140
- windowsVerbatimArguments: true,
141
- windowsHide: true,
142
- stdio: ['pipe', 'pipe', 'pipe'],
143
- });
144
- } else {
145
- child = spawn('codex', args, { stdio: ['pipe', 'pipe', 'pipe'] });
146
230
  }
231
+ const spec = codexSpawnSpec(args);
232
+ const child = spawn(spec.command, spec.args, {
233
+ ...spec.options,
234
+ stdio: ['pipe', 'pipe', 'pipe'],
235
+ });
147
236
 
148
237
  return new Promise((resolve) => {
149
238
  let failed = false;
@@ -249,20 +338,17 @@ export function runCodex(args, taskText, eventsPath, budgetMinutes, graceMs = ST
249
338
 
250
339
  /**
251
340
  * Codex missing or not authorised is a deterministic FAIL, never a reason to improvise.
252
- * Runs after the folder exists so this failure is recorded like any other.
341
+ * Plan_58, 2026-09-19: return a reason for refusal before registration, like the dead-sandbox
342
+ * probe. One no-quota question must not have two answers about whether it leaves a folder.
253
343
  */
254
- export function requireCodex(runDir, agent) {
344
+ export function codexUnavailableReason() {
255
345
  const probe = spawnSync(
256
346
  process.platform === 'win32' ? process.env.ComSpec || 'cmd.exe' : 'codex',
257
347
  process.platform === 'win32' ? ['/d', '/s', '/c', 'codex --version'] : ['--version'],
258
348
  { encoding: 'utf8' },
259
349
  );
260
350
  if (probe.error || probe.status !== 0) {
261
- const why = (probe.stderr || probe.error?.message || 'codex --version is not responding').trim();
262
- const { reply } = writeFailure(runDir, agent, `Codex CLI unavailable: ${why}`, [
263
- 'Operator check: codex --version (and codex login if authorization is rejected)',
264
- ], true);
265
- console.log(reply);
266
- process.exit(1);
351
+ return (probe.stderr || probe.error?.message || 'codex --version is not responding').trim();
267
352
  }
353
+ return null;
268
354
  }
@@ -40,7 +40,12 @@ export function worktreeSnapshot(repo) {
40
40
  const skip = runsPrefixInside(repo);
41
41
  // The path is the third tab-separated field in both git outputs used here.
42
42
  const mine = (line) => Boolean(skip) && (line.split('\t')[2] || '').startsWith(skip);
43
- const tracked = (git(repo, ['diff', 'HEAD', '--numstat']).stdout || '')
43
+ // `--no-renames` because every reader of this snapshot compares paths against a scope: with
44
+ // rename detection on, numstat prints one row spelled `old => new` (or `dir/{a => b}/file`),
45
+ // which is not a path and matches no pattern — an in-scope rename would be judged a stray, and
46
+ // the witness named that token at the orchestrator on 2026-09-20. Without it a rename is a
47
+ // deletion plus an addition: two rows, both real paths, both judged on their own merits.
48
+ const tracked = (git(repo, ['diff', 'HEAD', '--numstat', '--no-renames']).stdout || '')
44
49
  .split(/\r?\n/)
45
50
  .filter((line) => line.trim() && !mine(line))
46
51
  .join('\n')
@@ -15,7 +15,6 @@ import {
15
15
  writeStatus,
16
16
  markAbandoned,
17
17
  abandonedBranchDrift,
18
- activeRunDetails,
19
18
  chainRuns,
20
19
  startedRuns,
21
20
  taskFingerprint,
@@ -28,18 +27,21 @@ import { parseArgs, die } from './args.mjs';
28
27
  import { settleTaskInput } from './task-input.mjs';
29
28
  import { parseContinuationGrant } from '../required-inputs.mjs';
30
29
  import { continuationRefusal } from './continuation.mjs';
31
- import { SCHEMAS } from './schemas.mjs';
30
+ import { parseAdvisorTask } from '../meta/advisor-task.mjs';
31
+ import { schemaFor } from './schemas.mjs';
32
32
  import { INSTRUCTIONS } from './prompts.mjs';
33
33
  import { git, headSha, branchName, worktreeSnapshot, reviewScope } from './git-state.mjs';
34
34
  import { agentRole } from '../agents.mjs';
35
35
  import { codexArgs, runProfile } from './codex-args.mjs';
36
36
  import { writeWorkerOrder } from './worker-order.mjs';
37
- import { requireCodex, unsafeForCmd } from './codex-cmd.mjs';
37
+ import { unsafeForCmd } from './codex-cmd.mjs';
38
38
  import { runsRoot } from './runs-root.mjs';
39
39
  import { resolveProjectRunsDir } from './project-dir.mjs';
40
40
  import { cleanupRetention } from '../retention.mjs';
41
41
  import { renderConventions } from './conventions.mjs';
42
42
  import { validateScope } from './scope-check.mjs';
43
+ import { probeSandbox, sandboxRefusal } from './sandbox-probe.mjs';
44
+ import { preflightRefusal, resolveRunPhase, scopeRunRefusal, taskPreflight } from './preflight.mjs';
43
45
 
44
46
  /**
45
47
  * The worker is this same program re-invoked as `--worker <runDir>`, so the path spawned
@@ -86,13 +88,17 @@ export const runDirPath = (root, slug, runStamp = stamp()) => {
86
88
  /**
87
89
  * Everything a run needs before a single token of someone else's quota is spent, and every
88
90
  * refusal that costs nothing: bad arguments, an open chain without --continue, a busy
89
- * worktree, a missing Codex CLI. All of it happens here, in the process the caller is free
91
+ * worktree, a dead Codex sandbox, a missing Codex CLI. All in the process the caller is free
90
92
  * to kill — so a killed caller can only ever interrupt a run that was already paid for.
91
93
  */
92
94
  export async function launcher(argv = process.argv.slice(2)) {
93
95
  loadRunEnv();
94
96
  const opts = parseArgs(argv);
97
+ opts.phase = resolveRunPhase(opts, RUN_ENV.budgets);
95
98
  const taskText = settleTaskInput(opts);
99
+ // Plan_59 D5/D6: blind choices and design authority must be checked before the paid probe.
100
+ const taskGate = taskPreflight({ agent: opts.agent, taskText });
101
+ if (taskGate.refusal) die(taskGate.refusal, 1);
96
102
  const topLevel = git(opts.repo, ['rev-parse', '--show-toplevel']);
97
103
  const isGitRepo = topLevel.status === 0;
98
104
  const repoRoot = isGitRepo ? topLevel.stdout.trim() : opts.repo;
@@ -154,6 +160,10 @@ export async function launcher(argv = process.argv.slice(2)) {
154
160
  continuationGrant,
155
161
  );
156
162
  if (continuationError) die(continuationError);
163
+ // Plan_59 D14: the grant is valid by now; an advise pass may continue only an OK scope run.
164
+ const scopeRun = continuationGrant &&
165
+ scopeRunRefusal({ agent: opts.agent, phase: opts.phase, runsRoot: projectRunsRoot, grantRun: continuationGrant.run });
166
+ if (scopeRun) die(scopeRun, 1);
157
167
 
158
168
  // One order produced six Codex runs on 2026-08-03 because the caller's time ceiling made it
159
169
  // restart the synchronous launcher. A live same-order run is now the repeat target: attach
@@ -187,10 +197,19 @@ export async function launcher(argv = process.argv.slice(2)) {
187
197
  );
188
198
  }
189
199
 
190
- // Asked before this run registers itself, so it cannot find itself. Two writing runs share
191
- // one worktree with no isolation: the second one's before/after snapshot picks up the
192
- // first one's edits, and an honest run gets failed for work it never did.
193
- const busy = opts.agent === 'codex-build' ? activeRunDetails(projectRunsRoot, repoRoot) : null;
200
+ // Review 2026-09-17: the slow probe widened the window between the busy check and registration.
201
+ // Probe first so the busy check sees writers that registered while it waited.
202
+ // Keep dead refusal before retention to preserve old run artifacts.
203
+ const sandboxProbe = await probeSandbox({ agent: opts.agent, repo: repoRoot });
204
+ if (sandboxProbe.outcome === 'dead') die(sandboxRefusal(sandboxProbe));
205
+
206
+ // Everything that can refuse without touching the tree is asked here, after the probe so the
207
+ // busy check sees writers that registered while it waited, and before makeRunDir below: on
208
+ // 2026-09-19 a busy refusal left its folder inside ~/.claude and the live run's witness spent
209
+ // every tool call demanding the orchestrator revert a directory the tool itself had created.
210
+ // Exit 1, not the usage code 2: the order was correct, the host or the tree was not.
211
+ const preflightError = preflightRefusal({ agent: opts.agent, projectRunsRoot, repoRoot });
212
+ if (preflightError) die(preflightError, 1);
194
213
 
195
214
  let retention = null;
196
215
  try {
@@ -204,7 +223,7 @@ export async function launcher(argv = process.argv.slice(2)) {
204
223
  const runDir = makeRunDir(runDirPath(projectRunsRoot, opts.slug));
205
224
  setRun(runDir, opts.agent);
206
225
 
207
- // Written before Codex is even probed. From here on a killed runner leaves a folder that
226
+ // Written before the worker can start. From here on a killed runner leaves a folder that
208
227
  // says what it was and whose pid to check, instead of a folder that says nothing — the run
209
228
  // itself takes 20-25 minutes, far longer than the caller's default timeout, so being killed
210
229
  // mid-run is the normal way for this to end, not the exotic one.
@@ -220,8 +239,10 @@ export async function launcher(argv = process.argv.slice(2)) {
220
239
  agent: opts.agent,
221
240
  slug: opts.slug,
222
241
  order_id: opts.orderId,
242
+ phase: opts.phase,
243
+ ...(taskGate.advice === undefined ? {} : { advice: taskGate.advice }),
223
244
  // Fingerprint of the order, so a later run of the same task finds this one whatever it
224
- // calls itself. Written here, before Codex is probed, like everything the chain reads.
245
+ // calls itself. Written here, before Codex starts, like everything the chain reads.
225
246
  task_hash: taskHash,
226
247
  repo: repoRoot,
227
248
  started_at: new Date().toISOString(),
@@ -231,30 +252,13 @@ export async function launcher(argv = process.argv.slice(2)) {
231
252
  // `continued_from` is the exact run the orchestrator named; `continues` above remains the chain base.
232
253
  ...(continuationGrant ? { continued_from: continuationGrant.run } : {}),
233
254
  ...(retention ? { retention } : {}),
255
+ sandbox_probe: sandboxProbe,
234
256
  });
235
257
 
236
258
  // Printed before anything can go wrong: even a dispatcher that dies mid-run leaves the
237
259
  // orchestrator with a folder to look into.
238
260
  console.log(`RUN=${runDir} order-id=${opts.orderId}`);
239
261
 
240
- // Before requireCodex, so a blocked run costs nothing at all.
241
- if (busy) {
242
- const identityNote = busy.identity === 'unverified'
243
- ? '; process identity could not be confirmed'
244
- : '';
245
- const { reply } = writeFailure(
246
- runDir,
247
- opts.agent,
248
- `run ${busy.run} is already active for this repository; two writing runs in one tree are prohibited${identityNote}`,
249
- [`Active run: ${path.join(projectRunsRoot, busy.run)}`, 'Codex was not started; quota was not spent'],
250
- true,
251
- );
252
- console.log(reply);
253
- return 1;
254
- }
255
-
256
- requireCodex(runDir, opts.agent);
257
-
258
262
  const scope = opts.agent === 'codex-review' ? reviewScope(repoRoot, opts.changeset) : null;
259
263
  if (scope) {
260
264
  fs.writeFileSync(
@@ -270,6 +274,7 @@ export async function launcher(argv = process.argv.slice(2)) {
270
274
  if (opts.agent === 'codex-scout') {
271
275
  fs.writeFileSync(path.join(runDir, 'questions.json'), `${JSON.stringify(questions, null, 2)}\n`);
272
276
  }
277
+ if (opts.agent === 'codex-advisor') fs.writeFileSync(path.join(runDir, 'advisor-task.json'), `${JSON.stringify(parseAdvisorTask(taskText), null, 2)}\n`);
273
278
 
274
279
  if (opts.agent === 'codex-build') {
275
280
  fs.writeFileSync(path.join(runDir, 'scope.txt'), `${opts.scopePatterns.join('\n')}\n`);
@@ -315,7 +320,7 @@ export async function launcher(argv = process.argv.slice(2)) {
315
320
  fs.writeFileSync(path.join(runDir, 'task.md'), `${sections.join('\n\n')}\n`);
316
321
  fs.writeFileSync(
317
322
  path.join(runDir, 'schema.json'),
318
- `${JSON.stringify(SCHEMAS[opts.agent], null, 2)}\n`,
323
+ `${JSON.stringify(schemaFor(opts.agent, opts.phase), null, 2)}\n`,
319
324
  );
320
325
 
321
326
  if (opts.agent === 'codex-build') {
@@ -348,7 +353,8 @@ export async function launcher(argv = process.argv.slice(2)) {
348
353
  repo: repoRoot,
349
354
  isGitRepo,
350
355
  launcherPid: process.pid,
351
- budgetMinutes: RUN_ENV?.budgets?.[agentRole(opts.agent)],
356
+ phase: opts.phase,
357
+ budgetMinutes: RUN_ENV.budgets[agentRole(opts.agent)][opts.phase],
352
358
  scopeNew: opts.scopeNewPatterns,
353
359
  profile: runProfile({ ...opts, repo: repoRoot }),
354
360
  args: codexArgv,
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Refusals that must not touch the tree, decided before a run is registered. On 2026-09-19,
3
+ * a busy-tree refusal left its own folder in the live writer's witness. This boundary knows
4
+ * only the project runs root and existing holders, never the prospective run's directory.
5
+ */
6
+ import path from 'node:path';
7
+ import fs from 'node:fs';
8
+ import { advisorTaskRefusal } from '../meta/advisor-task.mjs';
9
+ import { activeRunDetails, readJson } from '../write-meta.mjs';
10
+ import { codexUnavailableReason } from './codex-cmd.mjs';
11
+ import { agentRole } from '../agents.mjs';
12
+ import { die } from './args.mjs';
13
+
14
+ /** Plan_59 D5/D6: settle design authority before any probe can spend quota. */
15
+ export function taskPreflight({ agent, taskText }) {
16
+ const freeRefusal = (reason) => ({
17
+ refusal: `${reason}\nThe run folder was not created; quota was not spent.`,
18
+ });
19
+ if (agent === 'codex-advisor') {
20
+ const reason = advisorTaskRefusal(taskText);
21
+ return reason ? freeRefusal(reason) : { refusal: null };
22
+ }
23
+ if (agent !== 'codex-build') return { refusal: null };
24
+ const lines = taskText.split(/\r\n|\n|\r/)
25
+ .flatMap((line) => {
26
+ const match = line.match(/^\s*(?:-\s+)?advice\s*:\s*(.*?)\s*$/i);
27
+ return match ? [match[1]] : [];
28
+ });
29
+ const advice = lines[0];
30
+ if (lines.length === 1) {
31
+ if (['mechanical', 'revert', 'docs-only', 'test-only'].includes(advice)) {
32
+ return { refusal: null, advice };
33
+ }
34
+ if (path.isAbsolute(advice)) {
35
+ try {
36
+ if (fs.statSync(advice).isDirectory() &&
37
+ readJson(path.join(advice, 'meta.json'))?.agent === 'codex-advisor') {
38
+ return { refusal: null, advice };
39
+ }
40
+ } catch {
41
+ // Plan_59 D6: missing or unreadable evidence cannot authorize a construction.
42
+ }
43
+ }
44
+ }
45
+ return freeRefusal('codex-build requires exactly one advice: line: mechanical | revert | docs-only | test-only ' +
46
+ 'or an absolute path to an existing advisor run directory with meta.json agent codex-advisor. ' +
47
+ 'An order that invents a construction needs a second opinion first; only work with no design choice may skip it.');
48
+ }
49
+
50
+ /** Plan_59 D14: an advice pass may continue only a completed, successful advisor scope run. */
51
+ export function scopeRunRefusal({ agent, phase, runsRoot, grantRun }) {
52
+ if (agent !== 'codex-advisor' || phase !== 'advise') return null;
53
+ let meta;
54
+ try {
55
+ meta = readJson(path.join(runsRoot, grantRun, 'meta.json'));
56
+ } catch {
57
+ meta = null;
58
+ }
59
+ const failed = [
60
+ meta?.agent === 'codex-advisor' ? null : "agent must be 'codex-advisor'",
61
+ meta?.phase === 'scope' ? null : "phase must be 'scope'",
62
+ meta?.status === 'OK' ? null : "status must be 'OK'",
63
+ ].filter(Boolean);
64
+ if (!failed.length) return null;
65
+ return `continued run meta.json failed ${failed.join(', ')}. The run folder was not created; quota was not spent.`;
66
+ }
67
+
68
+ /** D2: phase mistakes must refuse before even the sandbox probe can spend quota. */
69
+ export function resolveRunPhase({ agent, phase, continue: isContinue }, budgets) {
70
+ // Plan_59 D7: phase 2 must retain the scope run's predictions before quota is spent.
71
+ if (agent === 'codex-advisor' && phase === 'advise' && !isContinue) {
72
+ die('codex-advisor --phase advise requires --continue: phase 2 continues the scope run of the same order ' +
73
+ 'so it can settle the risks phase 1 predicted. The run folder was not created; quota was not spent.');
74
+ }
75
+ const phases = budgets[agentRole(agent)];
76
+ const names = Object.keys(phases);
77
+ if (phase === undefined && names.length === 1 && names[0] === 'default') return 'default';
78
+ if (phase !== undefined && Object.hasOwn(phases, phase)) return phase;
79
+ die(`${phase === undefined ? '--phase is required' : `undeclared --phase ${JSON.stringify(phase)}`} ` +
80
+ `for ${agent}. Action: pass --phase <name>; allowed phases: ${names.join(', ')}. ` +
81
+ 'The run folder was not created; quota was not spent.');
82
+ }
83
+
84
+ export function preflightRefusal({ agent, projectRunsRoot, repoRoot }) {
85
+ // Two writing runs share one tree: the second snapshot would include the first one's edits.
86
+ const busy = agent === 'codex-build' ? activeRunDetails(projectRunsRoot, repoRoot) : null;
87
+ if (busy) {
88
+ const identityNote = busy.identity === 'unverified'
89
+ ? '; process identity could not be confirmed'
90
+ : '';
91
+ return [
92
+ `run ${busy.run} is already active for this repository; two writing runs in one tree are prohibited${identityNote}`,
93
+ `Active run: ${path.join(projectRunsRoot, busy.run)}`,
94
+ // One sentence, the same one every other pre-flight refusal ends with. The older wording
95
+ // said the identical thing twice, which is how two copies of a rule start to drift.
96
+ 'The run folder was not created; quota was not spent.',
97
+ ].join('\n');
98
+ }
99
+
100
+ const why = codexUnavailableReason();
101
+ if (why !== null) {
102
+ return [
103
+ `Codex CLI unavailable: ${why}`,
104
+ 'Operator check: codex --version (and codex login if authorization is rejected)',
105
+ 'The run folder was not created; quota was not spent.',
106
+ ].join('\n');
107
+ }
108
+ return null;
109
+ }
@@ -8,6 +8,45 @@
8
8
  import { DEFAULTS } from '../run-config.mjs';
9
9
  import { RUN_ENV } from './run-env.mjs';
10
10
  const BODIES = {
11
+ // Plan_59 D3-D5/D7/D10: independent design advice needs explicit lenses and a risk ledger.
12
+ 'codex-advisor': (opts) => {
13
+ if (opts.phase !== 'scope' && opts.phase !== 'advise') {
14
+ throw new RangeError('codex-advisor instructions require phase scope or advise.');
15
+ }
16
+ return `Judge a DESIGN before code exists.
17
+
18
+ Rules for both phases:
19
+ - Read ONLY the paths listed under \`## Paths\`, plus \`git log\` / \`git show\` on those paths.
20
+ - Write nothing. Do your own reading and checks; do not spawn or delegate to other agents.
21
+ - Every claim about code must carry a \`path:line\` address inside those paths. Addresses are
22
+ machine-checked for existence; do not invent addresses or treat an unchecked claim as evidence.
23
+ - Do not guess which option the caller prefers: the task carries no preference on purpose.
24
+ - Two failure modes are forbidden: agreeing without independent checks ("both options are good",
25
+ "you know better"), and disagreeing to look independent. A disagreement without an address is
26
+ an opinion, not advice. Agreeing is fine when your own checks support it.
27
+ ${opts.phase === 'scope' ? `
28
+ Scope phase:
29
+ - BEFORE reading in depth, name 3-5 predicted_risks with ids r1..r5: where this design most likely
30
+ breaks. Record those predictions first, then read; do not replace them with hindsight.
31
+ - Answer sufficient, missing_paths (concrete repository paths you still need), and taken_on_trust
32
+ (facts accepted from the task without checking, never empty), together with predicted_risks.
33
+ ` : `
34
+ Advise phase:
35
+ - Settle every predicted risk from the scope run in risk_outcomes: its risk_id, an outcome of
36
+ confirmed or refuted, and an address supporting that outcome.
37
+ - Give ONE recommendation by task option_id, or none-of-these with a described unlisted_option.
38
+ Put every rejected task option in rejected with its cost; for none-of-these this means EVERY
39
+ task option. Set unlisted_option to "none" when recommending a listed option.
40
+ - Each field is a distinct lens: strongest_counterargument comes from a skeptic;
41
+ pre_mortem comes from the executor who has only this advice and the repository. Give each
42
+ scenario an early_check of kind test | command | inspect with a target; inspect requires an address.
43
+ - question_defect comes from the requester who needs the problem solved, not an option picked.
44
+ - Rate assumptions VERIFIED (requires an address) | REASONABLE | FRAGILE. Anything the requester
45
+ could refute with knowledge you lack belongs in open_questions, not why.
46
+ - independent_checks lists what you verified yourself, with addresses. Ground why in those checks
47
+ and state confidence honestly.
48
+ `}`;
49
+ },
11
50
  'codex-scout': (opts, scope, questions = []) => `Response rules:
12
51
  - You are working in read-only mode: do not try to write or edit anything.
13
52
  - Answer from your own reading: do not spawn or delegate to other agents.
@@ -0,0 +1,109 @@
1
+ /** Checks whether this host's Codex sandbox can start a process before a run spends quota. */
2
+ import { performance } from 'node:perf_hooks';
3
+ import { sandboxModeFor } from './codex-args.mjs';
4
+ import { codexSpawnSpec, spawnCaptured } from './codex-cmd.mjs';
5
+ import { platformSandboxArgs } from './sandbox-flags.mjs';
6
+
7
+ export const SANDBOX_PROBE_MARKER = 'codex-bridge-sandbox-ok';
8
+ // Plan_57 D12/D19: a platform is judged only after both outcomes were seen live. Linux joined on
9
+ // 2026-09-17: dead on an Ubuntu 24.04 VPS (bwrap without a user namespace, exit 1, no marker), alive
10
+ // on the same host after the AppArmor repair. macOS has never been observed.
11
+ export const PROBED_PLATFORMS = new Set(['win32', 'linux']);
12
+
13
+ // Ubuntu 23.10+ forbids unprivileged user namespaces unless an AppArmor profile allows bwrap; that
14
+ // is the ordinary state of a fresh server, not an accident like the corrupted Windows state file.
15
+ const LINUX_REPAIR = [
16
+ 'On Ubuntu 23.10 and later this usually means AppArmor does not let bubblewrap create a user namespace.',
17
+ 'Official repair: https://learn.chatgpt.com/docs/sandboxing — the bwrap-userns-restrict AppArmor profile',
18
+ '(Ubuntu 24.04) or the bubblewrap package (25.04 and later). Do not set',
19
+ 'kernel.apparmor_restrict_unprivileged_userns=0: it lifts the restriction for every program on the host.',
20
+ ].join(' ');
21
+
22
+ const echoArgs = (platform) => platform === 'win32'
23
+ ? ['cmd', '/d', '/c', 'echo', SANDBOX_PROBE_MARKER]
24
+ : ['echo', SANDBOX_PROBE_MARKER];
25
+
26
+ function inconclusiveReason(result, form) {
27
+ if (result.error) {
28
+ return result.error.code === 'ETIMEDOUT'
29
+ ? `The ${form} sandbox probe timed out.`
30
+ : `The ${form} sandbox probe could not start or complete.`;
31
+ }
32
+ if (result.signal) return `The ${form} sandbox probe was terminated by ${result.signal}.`;
33
+ if (result.status === 2) return `Codex rejected the ${form} sandbox probe arguments.`;
34
+ if (result.status == null) return `The ${form} sandbox probe returned no exit status.`;
35
+ return null;
36
+ }
37
+
38
+ export async function probeSandbox({
39
+ agent, repo, platform = process.platform, run = spawnCaptured, timeoutMs = 30_000,
40
+ }) {
41
+ if (!PROBED_PLATFORMS.has(platform)) return { outcome: 'skipped' };
42
+ const sandbox = sandboxModeFor(agent);
43
+ if (typeof repo !== 'string' || !repo) throw new Error('sandbox probe requires a repository path');
44
+ const echo = echoArgs(platform);
45
+ const attempts = [];
46
+ const finish = (outcome, reason) => ({ outcome, reason, attempts });
47
+ const attempt = async (form, args) => {
48
+ const spec = codexSpawnSpec(args, platform);
49
+ const started = performance.now();
50
+ const result = await run(spec.command, spec.args, {
51
+ ...spec.options, cwd: repo, encoding: 'utf8', timeout: timeoutMs,
52
+ });
53
+ const marker = String(result.stdout ?? '').includes(SANDBOX_PROBE_MARKER);
54
+ attempts.push({
55
+ form, status: result.status ?? null, marker,
56
+ ms: Math.round(performance.now() - started),
57
+ // D2: stderr is evidence for the operator, never input to the verdict.
58
+ stderrTail: String(result.stderr ?? '').slice(-300),
59
+ });
60
+ return { ...result, marker };
61
+ };
62
+
63
+ // On 2026-09-16 a corrupt deny_read_acl_state.json made every paid run unable to start tools.
64
+ // On 2026-09-17, -C required --permission-profile instead: cwd must be a process option.
65
+ // D16: no single failure exit code proves a dead sandbox; both forms and a live CLI must agree.
66
+ const flagged = await attempt('flagged', [
67
+ 'sandbox', ...platformSandboxArgs(platform), '-c', `sandbox_mode=${sandbox}`, '--', ...echo,
68
+ ]);
69
+ const flaggedReason = inconclusiveReason(flagged, 'flagged');
70
+ if (flaggedReason) return finish('inconclusive', flaggedReason);
71
+ if (flagged.status === 0 && flagged.marker) {
72
+ return finish('alive', 'The Codex sandbox started a process.');
73
+ }
74
+ // D16: the marker proves a process started, so a nonzero exit cannot prove a dead sandbox.
75
+ if (flagged.marker) {
76
+ return finish('inconclusive', `The flagged sandbox probe printed the marker but exited ${flagged.status}.`);
77
+ }
78
+
79
+ const control = await attempt('control', ['sandbox', '--', ...echo]);
80
+ const controlReason = inconclusiveReason(control, 'control');
81
+ if (controlReason) return finish('inconclusive', controlReason);
82
+ if (control.marker) {
83
+ return finish('inconclusive', 'This version of Codex no longer accepts the package sandbox flags.');
84
+ }
85
+
86
+ const version = await attempt('version', ['--version']);
87
+ if (version.error || version.signal || version.status !== 0) {
88
+ // codexUnavailableReason() owns the refusal for an unavailable CLI, in the same pre-flight pass.
89
+ return finish('inconclusive', 'Codex CLI is unavailable.');
90
+ }
91
+ return finish('dead', 'The Codex sandbox on this host cannot start a process.');
92
+ }
93
+
94
+ export function sandboxRefusal(result, platform = process.platform) {
95
+ const control = ['codex', 'sandbox', '--', ...echoArgs(platform)].join(' ');
96
+ const stderr = result.attempts
97
+ .filter(({ form }) => form === 'flagged' || form === 'control')
98
+ .map(({ form, stderrTail }) =>
99
+ `${form} stderr:\n> ${(stderrTail || '(empty)').replace(/\r\n?/g, '\n').replace(/\n/g, '\n> ')}`);
100
+ // Only a dead result is refused, and its reason already is the headline; printing both said it twice.
101
+ return [
102
+ result.reason,
103
+ 'Repair the host sandbox; do not rewrite or retry the order.',
104
+ ...(platform === 'linux' ? [LINUX_REPAIR] : []),
105
+ `Operator check (run from the repository directory): ${control}`,
106
+ ...stderr,
107
+ 'The run folder was not created; quota was not spent.',
108
+ ].join('\n\n');
109
+ }