@lyupro/codex-bridge 0.3.1 → 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.
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({
@@ -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;
@@ -89,6 +89,48 @@ export function extractValue(promptText, label) {
89
89
  return cleanValue(flagMatch[1] ?? flagMatch[2] ?? flagMatch[3]);
90
90
  }
91
91
 
92
+ const MAX_DIAGNOSIS_LINE_LENGTH = 160;
93
+
94
+ function readableDiagnosisLine(line) {
95
+ const trimmed = line.trim();
96
+ if (trimmed.length <= MAX_DIAGNOSIS_LINE_LENGTH) return trimmed;
97
+ return `${trimmed.slice(0, MAX_DIAGNOSIS_LINE_LENGTH - 1)}…`;
98
+ }
99
+
100
+ /** Explains the Plan_28 incident without widening the strict input parser's accepted spellings. */
101
+ export function diagnoseInput(promptText, label) {
102
+ if (typeof label !== 'string' || !label.trim()) return null;
103
+ const prompt = typeof promptText === 'string' ? promptText : '';
104
+ const labelPattern = escapeRegExp(label).replaceAll('\\ ', '\\s+');
105
+ const candidatePattern = new RegExp(
106
+ `^[ \\t]*(?:[-*][ \\t]*)?(?:[*_` + '`' + `]?${labelPattern}[*_` + '`' + `]?)(?=$|[^A-Za-z0-9_])[^\\r\\n]*`,
107
+ 'i',
108
+ );
109
+ const candidateLine = prompt.split(/\r?\n/).find((line) => candidatePattern.test(line));
110
+ if (candidateLine === undefined) return null;
111
+
112
+ const value = extractValue(candidateLine, label);
113
+ if (value !== null && !isPlaceholder(value)) return null;
114
+
115
+ const line = readableDiagnosisLine(candidateLine);
116
+ if (value !== null && isPlaceholder(value)) {
117
+ const displayedValue = value.trim();
118
+ if (!displayedValue) return { line, reason: 'value is empty; replace it with a concrete value' };
119
+ return {
120
+ line,
121
+ reason: `value \`${displayedValue}\` is a placeholder; replace it with a concrete value`,
122
+ };
123
+ }
124
+
125
+ const labelTail = candidateLine.match(
126
+ new RegExp(`^[ \\t]*(?:[-*][ \\t]*)?(?:[*_` + '`' + `]?${labelPattern}[*_` + '`' + `]?)([^\\r\\n]*)$`, 'i'),
127
+ )?.[1] ?? '';
128
+ if (labelTail.includes(':')) {
129
+ return { line, reason: `expected \`${label}:\` with nothing between the label and the colon` };
130
+ }
131
+ return { line, reason: `expected \`${label}: value\` with a separator immediately after the label` };
132
+ }
133
+
92
134
  /** Keeps the 2026-08-05 continuation incident's run and reason in the shared input parser. */
93
135
  export function parseContinuationGrant(promptText) {
94
136
  const prompt = typeof promptText === 'string' ? promptText : '';
@@ -19,6 +19,7 @@
19
19
  import fs from 'node:fs';
20
20
  import path from 'node:path';
21
21
  import { fileURLToPath } from 'node:url';
22
+ import { readJsonFileSync } from './json-file.mjs';
22
23
 
23
24
  const HERE = path.dirname(fileURLToPath(import.meta.url));
24
25
  export const CONFIG_PATH = path.join(HERE, 'run-config.json');
@@ -163,18 +164,13 @@ function readBudgets(file, value) {
163
164
  }
164
165
 
165
166
  export function readRunConfig(file = CONFIG_PATH) {
166
- let raw;
167
- try {
168
- raw = fs.readFileSync(file, 'utf8');
169
- } catch {
170
- return { ...DEFAULTS };
171
- }
172
167
  let parsed;
173
168
  try {
174
- parsed = JSON.parse(raw);
169
+ parsed = readJsonFileSync(file);
175
170
  } catch (err) {
171
+ if (err.code) return { ...DEFAULTS };
176
172
  throw new Error(
177
- `${file} cannot be parsed as JSON (${err.message}). Fix or delete the file — without it, ` +
173
+ `${file} cannot be parsed as JSON (${err.cause?.message || err.message}). Fix or delete the file — without it, ` +
178
174
  'the default mode applies: hooks and plugins are disabled.',
179
175
  );
180
176
  }
@@ -103,11 +103,25 @@ export function parseArgs(argv) {
103
103
  // told in prose not to touch them: prose does not bind, a file list does. Required rather
104
104
  // than defaulted to "everything", and checked here — before the folder exists and before a
105
105
  // single token of someone else's quota is spent.
106
- opts.scopePatterns = String(opts.scope || '')
106
+ const declaredScopePatterns = String(opts.scope || '')
107
107
  .split(',')
108
108
  .map((p) => p.trim())
109
109
  .filter(Boolean);
110
- if (opts.agent === 'codex-build' && !opts.scopePatterns.length) {
110
+ opts.scopeNewPatterns = String(opts['scope-new'] || '')
111
+ .split(',')
112
+ .map((p) => p.trim())
113
+ .filter(Boolean);
114
+ // Plan_27 keeps ordinary patterns strict: only paths named explicitly as new may be absent.
115
+ opts.scopePatterns = [...declaredScopePatterns, ...opts.scopeNewPatterns];
116
+ // Only the writing agent creates files, so only its scope may name one that does not exist yet.
117
+ // Accepting the flag elsewhere would hand scout and review a way to waive the check for nothing.
118
+ if (opts.agent !== 'codex-build' && opts.scopeNewPatterns.length) {
119
+ die(
120
+ `--scope-new is only for codex-build: ${opts.agent} does not create files. ` +
121
+ 'Action: drop --scope-new and pass every path through --scope.',
122
+ );
123
+ }
124
+ if (opts.agent === 'codex-build' && !declaredScopePatterns.length) {
111
125
  const scopeInput = requiredInput(opts.agent, 'scope');
112
126
  die(
113
127
  `--scope is required for codex-build: ${scopeInput.source} supplies the ${scopeInput.label}. ` +