@lyupro/codex-bridge 0.3.0 → 0.4.0

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 (45) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +27 -7
  3. package/bin/codex-bridge.mjs +28 -4
  4. package/cli/doctor.mjs +53 -3
  5. package/cli/install.mjs +8 -2
  6. package/cli/manifest.mjs +24 -12
  7. package/cli/permissions.mjs +179 -0
  8. package/cli/projects.mjs +17 -4
  9. package/cli/prune-args.mjs +1 -1
  10. package/cli/rules-owners.mjs +6 -8
  11. package/cli/runs-inventory.mjs +2 -1
  12. package/cli/settings-merge.mjs +93 -45
  13. package/cli/stop.mjs +33 -20
  14. package/cli/uninstall.mjs +23 -10
  15. package/cli/unlock.mjs +239 -0
  16. package/package.json +3 -2
  17. package/scripts/check-file-size.mjs +8 -10
  18. package/src/agents/codex-build.md +2 -0
  19. package/src/agents/codex-review.md +2 -0
  20. package/src/agents/codex-scout.md +2 -0
  21. package/src/cli-names.mjs +6 -0
  22. package/src/conventions.md +44 -0
  23. package/src/heartbeat.mjs +1 -1
  24. package/src/hooks/live-runs.mjs +15 -14
  25. package/src/hooks/order-gate.mjs +7 -2
  26. package/src/hooks/prune-guard.mjs +12 -4
  27. package/src/hooks/reply-guard.mjs +4 -3
  28. package/src/json-file.mjs +30 -0
  29. package/src/meta/chain.mjs +8 -6
  30. package/src/meta/paths.mjs +5 -9
  31. package/src/meta/pre-start.mjs +30 -0
  32. package/src/meta/run-state.mjs +31 -16
  33. package/src/process-identity.mjs +140 -0
  34. package/src/required-inputs.mjs +42 -0
  35. package/src/run-config.mjs +4 -8
  36. package/src/runner/args.mjs +16 -2
  37. package/src/runner/attach.mjs +23 -17
  38. package/src/runner/codex-cmd.mjs +1 -1
  39. package/src/runner/conventions.mjs +39 -0
  40. package/src/runner/launcher.mjs +43 -16
  41. package/src/runner/project-dir.mjs +4 -3
  42. package/src/runner/scope-check.mjs +159 -0
  43. package/src/runner/worker.mjs +13 -5
  44. package/src/write-meta.mjs +8 -1
  45. package/cli/sweep.mjs +0 -144
@@ -13,7 +13,7 @@
13
13
  * error mode, 2 internal error (config missing/malformed, or an exclude entry has no
14
14
  * matching exclusionRationale).
15
15
  *
16
- * Every exclude entry is classified as either a "pattern" (contains a `*`, sweeps a
16
+ * Every exclude entry is classified as either a "pattern" (contains a `*`, covers a
17
17
  * whole category — e.g. `**\/node_modules/**`) or a "specific path" (no `*`, names one
18
18
  * file). Specific paths must carry an exclusionRationale entry; patterns are exempt. This
19
19
  * is the structural guard against silently hiding a file in exclude without saying why.
@@ -21,6 +21,7 @@
21
21
  import fs from 'node:fs/promises';
22
22
  import path from 'node:path';
23
23
  import { fileURLToPath } from 'node:url';
24
+ import { readJsonFile } from '../src/json-file.mjs';
24
25
 
25
26
  const HERE = path.dirname(fileURLToPath(import.meta.url));
26
27
  export const REPO_ROOT = path.resolve(HERE, '..');
@@ -31,17 +32,14 @@ const VALID_MODES = ['error', 'warning'];
31
32
 
32
33
  /** Reads and structurally validates .file-size-limit.json. Throws on anything wrong. */
33
34
  export async function loadConfig(configPath = CONFIG_PATH) {
34
- let raw;
35
- try {
36
- raw = await fs.readFile(configPath, 'utf8');
37
- } catch (err) {
38
- throw new Error(`cannot read ${configPath}: ${err.message}`);
39
- }
40
35
  let parsed;
41
36
  try {
42
- parsed = JSON.parse(raw);
37
+ parsed = await readJsonFile(configPath);
43
38
  } catch (err) {
44
- throw new Error(`${configPath} is not valid JSON: ${err.message}`);
39
+ if (err.cause) {
40
+ throw new Error(`${configPath} is not valid JSON: ${err.cause.message}`, { cause: err.cause });
41
+ }
42
+ throw new Error(`cannot read ${configPath}: ${err.message}`);
45
43
  }
46
44
  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
47
45
  throw new Error(`${configPath} must contain a JSON object`);
@@ -69,7 +67,7 @@ export async function loadConfig(configPath = CONFIG_PATH) {
69
67
  return parsed;
70
68
  }
71
69
 
72
- /** A pattern (contains `*`) sweeps a category; a specific path (no `*`) names one file. */
70
+ /** A pattern (contains `*`) covers a category; a specific path (no `*`) names one file. */
73
71
  export function isSpecificExclude(entry) {
74
72
  return !entry.includes('*');
75
73
  }
@@ -28,6 +28,8 @@ job is to report the run status honestly, including failure.
28
28
 
29
29
  - The task statement: what to change and the completion criteria.
30
30
  - The path to the repository. If none is given, use the current working directory.
31
+ - Scope patterns are globs relative to the repository root. A pattern that matches nothing there is
32
+ refused before the run starts; a file this task is meant to create is declared with `--scope-new`.
31
33
  - Every input listed under **Required dispatcher inputs** above, passed on exactly as given:
32
34
  `order id` as `--order-id`, `scope` as `--scope`. Never invent a value, never edit one, never
33
35
  reuse an order id from another order — the runner chains runs by that label, and a made-up label
@@ -27,6 +27,8 @@ opinion, not a verdict.
27
27
  - branch against base — `--mode base:<branch>`;
28
28
  - specific commit — `--mode commit:<sha>`.
29
29
  - The path to the repository. If none is given, use the current working directory.
30
+ - Scope patterns are globs relative to the repository root. A pattern that matches nothing there is
31
+ refused before the run starts.
30
32
  - Every input listed under **Required dispatcher inputs** above, passed on exactly as given:
31
33
  `order id` as `--order-id`. Never invent a value, never edit one, never reuse an order id from
32
34
  another order — the runner chains runs by that label, and a made-up label is how a repeat run
@@ -23,6 +23,8 @@ files, do not run grep, do not retell the report, and do not reason about the ta
23
23
 
24
24
  - The task statement (what to find out / what to diagnose / what to review).
25
25
  - The path to the repository. If none is given, work in the current working directory.
26
+ - Scope patterns are globs relative to the repository root. A pattern that matches nothing there is
27
+ refused before the run starts.
26
28
  - Optional: `effort: <none|minimal|low|medium|high|xhigh|max>` — Codex reasoning depth.
27
29
  - Optional: `slug: <short-name>` for the run folder.
28
30
  - Every input listed under **Required dispatcher inputs** above, passed on exactly as given:
@@ -0,0 +1,6 @@
1
+ /** Names this CLI answers to. */
2
+
3
+ // Plan_17 §5 found installer and test hook lists drifting; keep executable spellings here so a
4
+ // new alias cannot silently bypass the prune guard. Under src/ because the installed guard reads
5
+ // it too, and cli/ is not copied into the host.
6
+ export const CLI_NAMES = Object.freeze(['codex-bridge', 'codexb']);
@@ -0,0 +1,44 @@
1
+ # Delegated-run conventions
2
+
3
+ These are the host conventions for every Codex run. Each block records a real acceptance
4
+ violation caught in this repository and turns it into a guard for the next run.
5
+
6
+ ## Task boundaries - Plan_16 acceptance incident
7
+
8
+ - Do not refactor code the task did not ask to change.
9
+ - Do not delete code on your own initiative.
10
+ - Do not change behavior branches whose behavior the task does not mention, even inside an
11
+ allowed file.
12
+
13
+ The 2026-08-06 Plan_16 acceptance finding applied a new reason order to a transport branch that
14
+ already named its reason. Preserve existing branches unless the task explicitly changes them.
15
+
16
+ ## Contracts without silent defaults - Plan_16 reasonFrom incident
17
+
18
+ - Require arguments that the contract needs instead of supplying a default.
19
+ - Fail loud when a required value is absent; do not silently fall back.
20
+ - Keep volatile identifiers such as model IDs and API versions in configuration, not runtime code.
21
+
22
+ The 2026-08-06 `reasonFrom(runDir, eventData = {})` finding replaced a required contract with a
23
+ silent default. Missing inputs must remain visible at the boundary that can explain them.
24
+
25
+ ## Structure and size - 2026-08-06 projects reload incident
26
+
27
+ - Keep every source file at or below 400 lines.
28
+ - Give each module one responsibility.
29
+ - Do not create `utils.mjs` dumping grounds.
30
+ - Never split coherent pieces solely to reach the line limit.
31
+
32
+ The 2026-08-06 `projects` reload finding exposed pseudo-duplicate exports and just-in-case code.
33
+ Keep related code together, but remove a real responsibility or create a focused module when a
34
+ boundary is needed.
35
+
36
+ ## Tests and comments - 2026-08-05 step 2b incident
37
+
38
+ - Add a guard test for every invariant accepted by the task.
39
+ - Splitting a file must delete no test case.
40
+ - Comments explain WHY and name the incident or decision that makes the rule necessary.
41
+
42
+ The 2026-08-05 step 2b acceptance found the abandoned-run check below the commit check. Test the
43
+ ordering invariant itself, and leave the incident in the comment so a later cleanup does not
44
+ silently undo it.
package/src/heartbeat.mjs CHANGED
@@ -51,7 +51,7 @@ export function heartbeatAge(runDir, now = Date.now()) {
51
51
 
52
52
  /**
53
53
  * How long a run has been silent, in words. It lives beside the age it formats because both the
54
- * worktree lock's refusal and `sweep`'s report have to name the same silence to the same operator;
54
+ * worktree lock's refusal and `unlock`'s report have to name the same silence to the same operator;
55
55
  * two copies of this drifted apart the moment they were written.
56
56
  */
57
57
  export function formatSilence(age) {
@@ -2,8 +2,8 @@
2
2
  * Shared, fail-open knowledge of live Codex runs.
3
3
  *
4
4
  * The reply guard and the worktree lock must answer the same question: a status.json is live
5
- * only while it says `running`, its runner pid accepts signal 0, and its progress heartbeat is
6
- * fresh. Keeping those probes and the directory scan here prevents the two protections from
5
+ * only while it says `running`, its runner pid is judged alive for this run, and its progress
6
+ * heartbeat is fresh. Keeping those probes and the directory scan here prevents the two protections from
7
7
  * disagreeing about a run, which would recreate the 2026-08-06 incident where a dead Codex
8
8
  * child left its worker and lock looking live through a grandchild-held stdio pipe.
9
9
  *
@@ -14,16 +14,17 @@
14
14
  import fs from 'node:fs';
15
15
  import path from 'node:path';
16
16
  import { isHeartbeatFresh } from '../heartbeat.mjs';
17
+ import { readJsonFileSync } from '../json-file.mjs';
18
+ import { IDENTITY_DEAD, IDENTITY_FOREIGN, processIdentity } from '../process-identity.mjs';
17
19
 
18
- /** Signal 0 tests existence without stopping the process; EPERM still means that it exists. */
19
- export const isPidAlive = (pid) => {
20
- if (typeof pid !== 'number') return false;
21
- try {
22
- process.kill(pid, 0);
23
- return true;
24
- } catch (err) {
25
- return err.code === 'EPERM';
26
- }
20
+ /**
21
+ * The hook requires pid judgment plus a fresh heartbeat. Identity uncertainty stays live here
22
+ * (fail-open), while the identity module deliberately treats a missing heartbeat as unverified;
23
+ * isHeartbeatFresh() keeps its pre-Plan_20 missing-file compatibility for this separate question.
24
+ */
25
+ export const isPidAlive = (pid, runDir, status = {}) => {
26
+ const identity = processIdentity({ runDir, status: { ...status, pid } });
27
+ return identity !== IDENTITY_DEAD && identity !== IDENTITY_FOREIGN;
27
28
  };
28
29
 
29
30
  /**
@@ -56,13 +57,13 @@ function recognizedStatus(runDir, status) {
56
57
  && isNonEmptyString(status.agent)
57
58
  && isNonEmptyString(status.slug)
58
59
  && isNonEmptyString(status.repo)
59
- && isPidAlive(status.pid)
60
- && isHeartbeatFresh(runDir);
60
+ && isHeartbeatFresh(runDir)
61
+ && isPidAlive(status.pid, runDir, status);
61
62
  }
62
63
 
63
64
  function readStatus(runDir) {
64
65
  try {
65
- return JSON.parse(fs.readFileSync(path.join(runDir, 'status.json'), 'utf8'));
66
+ return readJsonFileSync(path.join(runDir, 'status.json'));
66
67
  } catch {
67
68
  return null;
68
69
  }
@@ -14,7 +14,7 @@
14
14
  import fs from 'node:fs';
15
15
  import os from 'node:os';
16
16
  import path from 'node:path';
17
- import { missingInputs } from '../required-inputs.mjs';
17
+ import { diagnoseInput, missingInputs } from '../required-inputs.mjs';
18
18
  import { SUBAGENT_TOOLS } from '../hook-definitions.mjs';
19
19
 
20
20
  const HOME = os.homedir();
@@ -56,7 +56,12 @@ if (!missing.length) pass();
56
56
  const reason = [
57
57
  'Order gate denied the Agent call because required dispatcher input(s) are missing or still placeholders.',
58
58
  'Write each value in tool_input.prompt using `label: value` before launching the dispatcher:',
59
- ...missing.map((entry) => `- ${entry.label}: ${entry.explanation} Example: \`${entry.example}\`.`),
59
+ ...missing.flatMap((entry) => {
60
+ const lines = [`- ${entry.label}: ${entry.explanation} Example: \`${entry.example}\`.`];
61
+ const diagnosis = diagnoseInput(toolInput.prompt, entry.label);
62
+ if (diagnosis) lines.push(` found \`${diagnosis.line}\`, ${diagnosis.reason}`);
63
+ return lines;
64
+ }),
60
65
  ].join('\n');
61
66
 
62
67
  process.stdout.write(JSON.stringify({
@@ -11,6 +11,7 @@
11
11
  * Anything unrecognised passes. A guard that misreads its input must not break unrelated host work.
12
12
  */
13
13
  import fs from 'node:fs';
14
+ import { CLI_NAMES } from '../cli-names.mjs';
14
15
  import { SHELL_TOOLS } from '../hook-definitions.mjs';
15
16
 
16
17
  const SHELL_TOOL_NAMES = new Set(SHELL_TOOLS);
@@ -18,10 +19,17 @@ const pass = () => process.exit(0);
18
19
 
19
20
  // The binary, then the verb, with any path in front of it: an agent reaches the CLI as
20
21
  // `codex-bridge`, as `node bin/codex-bridge.mjs`, or through an absolute path, and a guard that
21
- // only knew the bare name would be bypassed by the spelling everyone uses inside a clone. Matching
22
- // a lone `prune` is not an option either — `git prune` and `npm prune` are not this package's
23
- // business, and a guard that breaks unrelated work is a guard that gets uninstalled.
24
- const PRUNE_CALL = /(^|[\s;&|(`])(\S*[\\/])?codex-bridge(\.mjs)?\s+prune(\s|$)/;
22
+ // only knew the bare name would be bypassed by the spelling everyone uses inside a clone. Plan_19
23
+ // adds a second executable spelling, so build this matcher from the shared list instead of
24
+ // repeating names here. Matching a lone `prune` is not an option either — `git prune` and
25
+ // `npm prune` are not this package's business, and a guard that breaks unrelated work is a guard
26
+ // that gets uninstalled.
27
+ const escapeRegex = (name) => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
28
+ const CLI_NAME_PATTERN = CLI_NAMES.map(escapeRegex).join('|');
29
+ const PRUNE_CALL = new RegExp(
30
+ '(^|[\\s;&|(`])(\\S*[\\\\/])?(?:' + CLI_NAME_PATTERN
31
+ + ')(\\.mjs)?\\s+prune(\\s|$)',
32
+ );
25
33
 
26
34
  let input;
27
35
  try {
@@ -29,6 +29,7 @@
29
29
  import fs from 'node:fs';
30
30
  import os from 'node:os';
31
31
  import path from 'node:path';
32
+ import { readJsonFileSync } from '../json-file.mjs';
32
33
  import { isPidAlive, liveRuns, normalizePath } from './live-runs.mjs';
33
34
 
34
35
  const HOME = os.homedir();
@@ -120,7 +121,7 @@ const takeTry = (agentId, kind) => {
120
121
  if (!agentId) return 'untracked';
121
122
  let seen;
122
123
  try {
123
- seen = JSON.parse(fs.readFileSync(BLOCKED_FILE, 'utf8'));
124
+ seen = readJsonFileSync(BLOCKED_FILE);
124
125
  } catch {
125
126
  seen = {};
126
127
  }
@@ -253,7 +254,7 @@ const statusPath = path.join(runDir, 'status.json');
253
254
  if (fs.existsSync(statusPath)) {
254
255
  let runStatus = null;
255
256
  try {
256
- runStatus = JSON.parse(fs.readFileSync(statusPath, 'utf8'));
257
+ runStatus = readJsonFileSync(statusPath);
257
258
  } catch {
258
259
  runStatus = null;
259
260
  }
@@ -317,7 +318,7 @@ if (!fs.existsSync(metaPath)) {
317
318
 
318
319
  let meta;
319
320
  try {
320
- meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
321
+ meta = readJsonFileSync(metaPath);
321
322
  } catch {
322
323
  pass();
323
324
  }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Reads JSON written by another program and keeps its file boundary in one place.
3
+ *
4
+ * Plan_24 records the live Windows incident: PowerShell Out-File -Encoding utf8 wrote a BOM
5
+ * that Claude Code accepted but JSON.parse rejected. Readers must strip that BOM consistently.
6
+ */
7
+ import fs from 'node:fs';
8
+ import fsp from 'node:fs/promises';
9
+
10
+ function parseJson(file, raw) {
11
+ const text = raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw;
12
+ try {
13
+ return JSON.parse(text);
14
+ } catch (error) {
15
+ throw new Error(`cannot parse ${file}: ${error.message}`, { cause: error });
16
+ }
17
+ }
18
+
19
+ export async function readJsonFile(file) {
20
+ return parseJson(file, await fsp.readFile(file, 'utf8'));
21
+ }
22
+
23
+ export async function readJsonFileWithRaw(file) {
24
+ const raw = await fsp.readFile(file, 'utf8');
25
+ return { raw, value: parseJson(file, raw) };
26
+ }
27
+
28
+ export function readJsonFileSync(file) {
29
+ return parseJson(file, fs.readFileSync(file, 'utf8'));
30
+ }
@@ -9,6 +9,7 @@
9
9
  import fs from 'node:fs';
10
10
  import path from 'node:path';
11
11
  import { createHash } from 'node:crypto';
12
+ import { startedRuns } from './pre-start.mjs';
12
13
  import { normalizePath, readJson } from './paths.mjs';
13
14
 
14
15
  /**
@@ -69,15 +70,16 @@ export function chainRuns(runsRoot, repo, slug, taskHash = '', orderId = '') {
69
70
  }
70
71
 
71
72
  /**
72
- * The tree as it stood before the task began: state-before.txt of the FIRST run of the
73
- * chain. The first and not the previous one, because a middle run can end without an
74
- * "after" snapshot at all — 2026-07-31_114736 wrote eleven files and left neither
75
- * state-after.txt nor meta.json — and its own "before" is then the last honest base.
76
- * null when there is no chain or that first run never got a snapshot; an existing but
73
+ * The tree as it stood before the task began: state-before.txt of the FIRST STARTED run of the
74
+ * chain. A pre-start folder has no snapshot, so treating it as first made the next pass lose
75
+ * its baseline in the Plan_23 incident; after that, the first started run is still preferred
76
+ * over a later one because a middle run can end without an "after" snapshot at all.
77
+ * null when there is no started run or that first run never got a snapshot; an existing but
77
78
  * empty snapshot is a clean tree, which is data, not absence.
78
79
  */
79
80
  export function chainBaseline(runsRoot, repo, slug, taskHash = '', orderId = '') {
80
- const [first] = chainRuns(runsRoot, repo, slug, taskHash, orderId);
81
+ const chain = chainRuns(runsRoot, repo, slug, taskHash, orderId);
82
+ const [first] = startedRuns(runsRoot, chain);
81
83
  if (!first) return null;
82
84
  try {
83
85
  return fs.readFileSync(path.join(runsRoot, first, 'state-before.txt'), 'utf8');
@@ -4,9 +4,10 @@
4
4
  * Two sides spell the same file differently — git prints repo-relative paths with forward
5
5
  * slashes, Codex writes whatever it likes — so nothing above this module compares a path
6
6
  * before it has been through here. This is also the only module that touches the
7
- * filesystem for reading: everything else receives text, JSON or a byte count from it.
7
+ * filesystem for reading artifacts: everything else receives text, JSON or a byte count from it.
8
8
  */
9
9
  import fs from 'node:fs';
10
+ import { readJsonFileSync } from '../json-file.mjs';
10
11
 
11
12
  /** One line, no newlines, bounded length — a five-line reply must stay five lines. */
12
13
  export const line = (value, max = 200) => String(value ?? '').replace(/\s+/g, ' ').trim().slice(0, max);
@@ -20,17 +21,12 @@ export const readText = (file) => {
20
21
  };
21
22
 
22
23
  /**
23
- * A run's JSON artifact, or null when there is none to read. The byte-order mark is stripped
24
- * first: JSON.parse rejects it, and a status.json rewritten by a Windows editor or by
25
- * PowerShell's `Set-Content -Encoding utf8` would then read as "no such run" — silently
26
- * excusing that run from every check that looks it up.
24
+ * A run's JSON artifact, or null when there is none to read. The shared reader strips the
25
+ * byte-order mark that PowerShell's `Out-File -Encoding utf8` added in the Plan_24 incident.
27
26
  */
28
27
  export const readJson = (file) => {
29
28
  try {
30
- const text = fs.readFileSync(file, 'utf8');
31
- // Compared by code point rather than matched by a literal: a byte-order mark in the
32
- // source of this file would be invisible to the next reader.
33
- return JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);
29
+ return readJsonFileSync(file);
34
30
  } catch {
35
31
  return null;
36
32
  }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Identifies runs that never started Codex and removes them from paid-run views.
3
+ *
4
+ * The explicit state is the durable contract for new guard refusals; the second branch keeps
5
+ * folders written before this fix from poisoning the next launch. Do not infer this from
6
+ * events.jsonl: retention.mjs deletes that transport file from old paid runs, while
7
+ * meta.events_bytes is recorded at verdict time and survives cleanup. The Plan_23 incident
8
+ * showed why confusing those cases would remove repeat protection from a run that spent quota.
9
+ */
10
+
11
+ import path from 'node:path';
12
+
13
+ import { readJson } from './paths.mjs';
14
+
15
+ export function abortedPreStart(runDir) {
16
+ const status = readJson(path.join(runDir, 'status.json'));
17
+ if (status?.state === 'aborted_pre_start') return true;
18
+ if (status?.state !== 'failed') return false;
19
+
20
+ const meta = readJson(path.join(runDir, 'meta.json'));
21
+ return meta?.exit === null
22
+ && meta?.session_id == null
23
+ && meta?.events_bytes === 0
24
+ && meta?.stderr_bytes === 0
25
+ && meta?.tokens_reported !== true;
26
+ }
27
+
28
+ export function startedRuns(runsRoot, runs) {
29
+ return runs.filter((run) => !abortedPreStart(path.join(runsRoot, run)));
30
+ }
@@ -10,24 +10,26 @@
10
10
  import fs from 'node:fs';
11
11
  import path from 'node:path';
12
12
  import { changedPaths, line, normalizePath, readJson, readText, size } from './paths.mjs';
13
+ import {
14
+ IDENTITY_ALIVE,
15
+ IDENTITY_UNVERIFIED,
16
+ processIdentity,
17
+ } from '../process-identity.mjs';
13
18
 
14
19
  /**
15
- * Is this pid still running? EPERM means the process exists and belongs to someone else,
16
- * which for our purposes is alive — treating it as dead would mark a live run abandoned.
20
+ * Is this pid still running for this run? Unknown identity remains live here: treating an
21
+ * unverified process as dead would mark a live run abandoned, and readers must fail open.
17
22
  */
18
- export const pidAlive = (pid) => {
19
- if (!Number.isInteger(pid) || pid <= 0) return false;
20
- try {
21
- process.kill(pid, 0);
22
- return true;
23
- } catch (err) {
24
- return err.code === 'EPERM';
25
- }
23
+ const isAliveIdentity = (identity) => identity === IDENTITY_ALIVE || identity === IDENTITY_UNVERIFIED;
24
+
25
+ const pidAlive = (pid, runDir, status = {}) => {
26
+ const identity = processIdentity({ runDir, status: { ...status, pid } });
27
+ return isAliveIdentity(identity);
26
28
  };
27
29
 
28
30
  /**
29
- * This module deliberately judges by the pid alone, while hooks/live-runs.mjs also requires a
30
- * fresh progress heartbeat. The two answer different questions, and merging them broke both:
31
+ * This module deliberately judges by the run's pid identity alone, while hooks/live-runs.mjs also
32
+ * requires a fresh progress heartbeat. The two answer different questions, and merging them broke both:
31
33
  *
32
34
  * - a hook asks "may the operator edit this tree", and a stalled run must stop holding that
33
35
  * lock — the operator is editing knowingly, and a stale run should not cost them 25 minutes;
@@ -80,7 +82,8 @@ export function markAbandoned(runsRoot, currentTree) {
80
82
  if (!entry.isDirectory()) continue;
81
83
  const runDir = path.join(runsRoot, entry.name);
82
84
  const status = readJson(path.join(runDir, 'status.json'));
83
- if (!status || status.state !== 'running' || pidAlive(status.pid)) continue;
85
+ if (!status || status.state !== 'running') continue;
86
+ if (pidAlive(status.pid, runDir, status)) continue;
84
87
  const metaPath = path.join(runDir, 'meta.json');
85
88
  const meta = readJson(metaPath);
86
89
  if (!meta && fs.existsSync(metaPath)) continue;
@@ -138,6 +141,11 @@ export function markAbandoned(runsRoot, currentTree) {
138
141
  * are not looked at — running scouting alongside a build is the normal way to work.
139
142
  */
140
143
  export function activeRun(runsRoot, repo, agent = 'codex-build') {
144
+ return activeRunDetails(runsRoot, repo, agent)?.run || null;
145
+ }
146
+
147
+ /** Finds the same active run while retaining identity uncertainty for a refusal message. */
148
+ export function activeRunDetails(runsRoot, repo, agent = 'codex-build') {
141
149
  let entries;
142
150
  try {
143
151
  entries = fs.readdirSync(runsRoot, { withFileTypes: true });
@@ -151,7 +159,8 @@ export function activeRun(runsRoot, repo, agent = 'codex-build') {
151
159
  const status = readJson(path.join(runDir, 'status.json'));
152
160
  if (!status || status.state !== 'running' || status.agent !== agent) continue;
153
161
  if (normalizePath(status.repo) !== wanted) continue;
154
- if (pidAlive(status.pid)) return entry.name;
162
+ const identity = processIdentity({ runDir, status });
163
+ if (isAliveIdentity(identity)) return { run: entry.name, identity };
155
164
  }
156
165
  return null;
157
166
  }
@@ -194,7 +203,9 @@ export function abandonedBranchDrift(runsRoot, repo, currentBranch) {
194
203
  * the runner itself. It still goes through meta.json, so there is no second path by
195
204
  * which a reply could exist without an artifact backing it.
196
205
  */
197
- export function writeFailure(runDir, agent, reason, extraLines = []) {
206
+ // The marker is reserved for refusals before Codex starts; Plan_23 showed that calling those
207
+ // ordinary failures makes a no-quota folder consume the same continuation slot as paid work.
208
+ export function writeFailure(runDir, agent, reason, extraLines = [], preStart = false) {
198
209
  const meta = {
199
210
  agent,
200
211
  project: path.basename(path.dirname(runDir)),
@@ -214,7 +225,11 @@ export function writeFailure(runDir, agent, reason, extraLines = []) {
214
225
  env: readJson(path.join(runDir, 'env.json')),
215
226
  };
216
227
  fs.writeFileSync(path.join(runDir, 'meta.json'), `${JSON.stringify(meta, null, 2)}\n`);
217
- writeStatus(runDir, { state: 'failed', status: 'FAIL', finished_at: meta.finished_at });
228
+ writeStatus(runDir, {
229
+ state: preStart ? 'aborted_pre_start' : 'failed',
230
+ status: 'FAIL',
231
+ finished_at: meta.finished_at,
232
+ });
218
233
  const reply = [`FAIL — ${meta.reason}`, ...extraLines, `Run: ${runDir}`].join('\n');
219
234
  return { meta, reply };
220
235
  }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Answers whether a recorded pid belongs to the process that started this run.
3
+ *
4
+ * Signal 0 is the cheap usual probe. A permission error means only that a process exists, not
5
+ * that it is this run's process, so a stale or missing heartbeat falls through to the OS start
6
+ * time probe. `foreign` is a known-dead diagnostic for callers that need to explain pid reuse;
7
+ * readers treat it like `dead`, while `unverified` remains fail-open.
8
+ */
9
+ import { spawnSync } from 'node:child_process';
10
+ import { heartbeatAge, HEARTBEAT_STALE_MS } from './heartbeat.mjs';
11
+
12
+ export const IDENTITY_ALIVE = 'alive';
13
+ export const IDENTITY_DEAD = 'dead';
14
+ export const IDENTITY_FOREIGN = 'foreign';
15
+ export const IDENTITY_UNVERIFIED = 'unverified';
16
+ export const PROCESS_START_TOLERANCE_MS = 1_000;
17
+ export const LEGACY_START_GRACE_MS = 30_000;
18
+
19
+ const signalZero = (pid) => process.kill(pid, 0);
20
+
21
+ function defaultCommandRunner(command, args) {
22
+ return spawnSync(command, args, { encoding: 'utf8', windowsHide: true });
23
+ }
24
+
25
+ function timestamp(value) {
26
+ if (value instanceof Date) return value.getTime();
27
+ if (typeof value === 'number') return Number.isFinite(value) ? value : null;
28
+ if (typeof value !== 'string' || !value.trim()) return null;
29
+ const parsed = Date.parse(value);
30
+ return Number.isFinite(parsed) ? parsed : null;
31
+ }
32
+
33
+ function commandStartTime(pid, commandRunner = defaultCommandRunner) {
34
+ try {
35
+ const command = process.platform === 'win32' ? 'powershell.exe' : 'ps';
36
+ const args = process.platform === 'win32'
37
+ ? [
38
+ '-NoProfile',
39
+ '-NonInteractive',
40
+ '-Command',
41
+ // Get-Process answers only for processes of the same user, and the pid this exists to
42
+ // recognise was taken by a SYSTEM service — so CIM has to answer after it. Get-CimInstance
43
+ // returns CreationDate as a DateTime already; the DMTF converter belongs to the older
44
+ // Get-WmiObject and throws "dmtfDate out of range" on this value, which cost this probe
45
+ // its whole purpose once. No $ErrorActionPreference = 'Stop' either: one failing source
46
+ // must fall through to the next rather than end the script.
47
+ [
48
+ `$process = Get-Process -Id ${pid} -ErrorAction SilentlyContinue`,
49
+ "if ($process) { try { $start = $process.StartTime; if ($start) { $start.ToUniversalTime().ToString('o'); exit 0 } } catch {} }",
50
+ `$cim = Get-CimInstance Win32_Process -Filter \"ProcessId=${pid}\" -ErrorAction SilentlyContinue`,
51
+ "if ($cim -and $cim.CreationDate) { $cim.CreationDate.ToUniversalTime().ToString('o') }",
52
+ ].join('; '),
53
+ ]
54
+ : ['-o', 'lstart=', '-p', String(pid)];
55
+ const result = commandRunner(command, args);
56
+ if (!result || result.error || result.status !== 0) return null;
57
+ const output = String(result.stdout ?? '').trim();
58
+ return timestamp(output);
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ function heartbeatIsFreshForIdentity(runDir, now) {
65
+ if (typeof runDir !== 'string' || !runDir.trim()) return false;
66
+ const age = heartbeatAge(runDir, now);
67
+ // isHeartbeatFresh() deliberately treats a missing file as fresh for pre-Plan_20 records.
68
+ // Identity is stricter: a missing heartbeat proves nothing about THIS process, so it must probe.
69
+ return age !== null && age <= HEARTBEAT_STALE_MS;
70
+ }
71
+
72
+ function optionsFor(input) {
73
+ return input && typeof input === 'object' ? input : {};
74
+ }
75
+
76
+ export function processAlive(pid, kill = signalZero) {
77
+ if (!Number.isInteger(pid) || pid <= 0 || pid > 0x7fff_ffff) return false;
78
+ try {
79
+ kill(pid, 0);
80
+ return true;
81
+ } catch (error) {
82
+ return error?.code === 'EACCES' || error?.code === 'EPERM';
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Return alive/dead/unverified, with foreign as the explicit known-not-this-run case.
88
+ *
89
+ * The probe and signal-0 runner are injectable so tests can exercise every branch without
90
+ * touching a real process. `commandRunner` receives the same command/arguments pair as spawnSync.
91
+ */
92
+ export function processIdentity(input = {}) {
93
+ const options = optionsFor(input);
94
+ const status = options.status && typeof options.status === 'object' ? options.status : options;
95
+ const pid = options.pid ?? status.pid;
96
+ if (!Number.isInteger(pid) || pid <= 0 || pid > 0x7fff_ffff) return IDENTITY_DEAD;
97
+
98
+ const kill = options.kill || options.signalZero || signalZero;
99
+ try {
100
+ kill(pid, 0);
101
+ } catch (error) {
102
+ if (error?.code === 'ESRCH') return IDENTITY_DEAD;
103
+ if (error?.code === 'EINVAL') return IDENTITY_DEAD;
104
+ if (error?.code !== 'EACCES' && error?.code !== 'EPERM') return IDENTITY_UNVERIFIED;
105
+ }
106
+
107
+ const now = options.now ?? Date.now();
108
+ if (!options.ignoreHeartbeat && heartbeatIsFreshForIdentity(options.runDir, now)) {
109
+ return IDENTITY_ALIVE;
110
+ }
111
+
112
+ const probe = options.probe || options.processStartProbe
113
+ || ((value) => commandStartTime(value, options.commandRunner));
114
+ let processStartedAt;
115
+ try {
116
+ processStartedAt = timestamp(probe(pid, options));
117
+ } catch {
118
+ processStartedAt = null;
119
+ }
120
+ if (processStartedAt === null) return IDENTITY_UNVERIFIED;
121
+
122
+ const hasRecordedStart = Object.prototype.hasOwnProperty.call(status, 'process_started_at')
123
+ && status.process_started_at !== null
124
+ && status.process_started_at !== '';
125
+ if (hasRecordedStart) {
126
+ const recordedStart = timestamp(status.process_started_at);
127
+ if (recordedStart === null) return IDENTITY_UNVERIFIED;
128
+ return Math.abs(processStartedAt - recordedStart) <= PROCESS_START_TOLERANCE_MS
129
+ ? IDENTITY_ALIVE
130
+ : IDENTITY_FOREIGN;
131
+ }
132
+
133
+ const startedAt = timestamp(status.started_at);
134
+ if (startedAt === null) return IDENTITY_UNVERIFIED;
135
+ return processStartedAt > startedAt + LEGACY_START_GRACE_MS
136
+ ? IDENTITY_FOREIGN
137
+ : IDENTITY_ALIVE;
138
+ }
139
+
140
+ export const probeProcessStart = commandStartTime;