@lyupro/codex-bridge 0.6.2 → 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,107 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.6.4] - 2026-09-21
8
+
9
+ ### Fixed
10
+
11
+ - A refusal that spends no quota no longer leaves a run folder behind. A second writing run against a
12
+ busy tree was refused correctly, but its folder was created first — and in a repository that hosts
13
+ the run folders, the live writer's witness then spent every tool call ordering the orchestrator to
14
+ revert a directory the tool itself had made, or to stop an honest run. Neither is a safe action. The
15
+ misplaced check was the symptom: refusals sat on two sides of the registration boundary with no rule
16
+ about which side was which, and two gates of the same class already disagreed — a dead sandbox
17
+ refused without a folder, a busy tree with one. The quota-free host checks now live in a module that
18
+ is handed no run directory, so a check added there cannot register a run, and a table test holds
19
+ every refusal with its side and the reason for that side. A busy tree and an unavailable Codex CLI
20
+ therefore write nothing at all: they no longer produce an `aborted_pre_start` folder, and their
21
+ exit code stays 1, because the order was correct and the host or the tree was not.
22
+ - The live witness and the final verdict judge one worktree by one definition. The witness read
23
+ `git status --porcelain` on its own while the verdict reads the recorded snapshot, so three kinds of
24
+ change were visible to one and invisible to the other: the run's own folder, the orchestrator's
25
+ environment writes, and gitignored working notes. The witness now composes the same chain as the
26
+ verdict, and its message says first that those three are already excluded — so what it lists is the
27
+ orchestrator's own edit — and only then offers the two actions. A structural test fails any hook that
28
+ spells a worktree query in an argument list; this drift went unnoticed for months and surfaced only
29
+ as a false accusation against a run that was doing its job.
30
+ - A file moved inside its own scope is no longer a stray. The shared snapshot was read with git's
31
+ rename detection on, which prints one row spelled `old => new` — not a path, and matching no scope
32
+ pattern. The witness named that token at the orchestrator, and the verdict would have failed an
33
+ honest build for a `git mv` it was told to make. The snapshot is now read with `--no-renames`: a
34
+ rename is a deletion plus an addition, two real paths, each judged on its own merits.
35
+ - A scope pattern that can only name a directory is refused before the run starts. A scope is compared
36
+ against file paths, never directories, so `muse/scripts` authorises one file that will never exist
37
+ while everything created inside the folder counts as a stray. Declared patterns escaped this by luck
38
+ — `git ls-files` prints no directories — but `--scope-new` is exempt from the existence check by
39
+ design, and a run ordered with `--scope-new muse/scripts` worked ten minutes, returned a filled
40
+ result, and was failed for the three files it created there. A trailing slash, a name that is a
41
+ directory on disk, and a glob-free name whose last segment has no extension are now refused before
42
+ the run folder exists, with the spelling that works named in the refusal. Patterns containing a glob
43
+ are never judged by these rules.
44
+
45
+ ## [0.6.3] - 2026-09-17
46
+
47
+ ### Added
48
+
49
+ - A dead Codex sandbox refuses the run before it spends quota. On 2026-09-16 an unclean Windows
50
+ shutdown left the sandbox helper's state file as 22 zero bytes, and every delegated run after it
51
+ paid for a session in which not one command ran — one scout even came back `OK`. The launcher now
52
+ asks the sandbox to echo a marker before the run folder exists, and calls it dead only on double
53
+ evidence: the role's own flags and a flagless control both produce no marker, neither reports an
54
+ argument error, and the CLI still answers `--version`. Codex had already changed the shape of its
55
+ own sandbox command once, so anything the probe cannot judge lets the run start and is named in
56
+ the reply — a release that stops accepting the package's sandbox flags cannot turn the check off
57
+ silently. `stderr` is quoted for the operator and never parsed. The probe is bounded on every
58
+ path: a hung attempt stops the whole Codex tree rather than the `cmd.exe` in front of it, and a
59
+ capture that overflows is inconclusive, never dead. Windows and Linux are judged, each after both
60
+ outcomes were seen live on it; on Linux, where a dead sandbox is the ordinary state of a fresh
61
+ Ubuntu 23.10+ server, the refusal names the AppArmor profile that repairs it and warns against
62
+ lifting the restriction through `sysctl`. macOS is skipped — no outcome has been observed there.
63
+
64
+ ### Fixed
65
+
66
+ - A scout that executed no command can no longer come back `OK`. On the dead sandbox of 2026-09-16
67
+ a scout was graded `OK` without reading a single file: its evidence explained that the files were
68
+ unreadable, and the coverage gate only checked that the evidence was a non-empty string. The
69
+ verdict now requires a fact before it judges coverage — at least one completed
70
+ `command_execution` in `events.jsonl`, with any status or exit code. Refused calls never enter the
71
+ stream, which is what makes the fact reliable; across 99 earlier successful scout runs it was
72
+ missing exactly twice, both on that sandbox.
73
+ - Quoted text no longer reads as shell commands to the worktree lock. The write-intent scanner read
74
+ quotes three different ways, and its raw split on `;` and `|` cut inside them: an `awk` script
75
+ containing "Do not touch any file outside" was refused on 2026-09-16 as a write to `any`, `file`
76
+ and `outside` — the fourth refusal on one root after `node -e`, `sed` and a heredoc body. One
77
+ lexer now owns quotes, escapes and command boundaries for all three consumers, drops comments, and
78
+ opens subshells and `$(…)` only where the shell does.
79
+ - An argument that happens to be a command name is not a write. `git log --grep rm -- README.md` and
80
+ `grep -rn touch src/` were refused while a build held the repository, because the scanner looked
81
+ for `rm` and `touch` in any word. A command name now counts only in command position — after
82
+ assignments, reserved words, leading redirections, a short list of launchers, and a `find -exec`
83
+ action up to its terminator.
84
+ - A stop at the deadline cannot block the launcher forever. `stopCodex()` gave `taskkill` no timeout,
85
+ so a stalled `taskkill` held the event loop and every settlement timer with it. It now has ten
86
+ seconds, after which the direct child is killed.
87
+ - The response guard judges a run by its own process, not by a bare pid. Without the run folder or
88
+ the recorded start time, a pid reused by another process after a reboot read as "the run is still
89
+ working", and the guard held the dispatcher on a dead run until the session's retry budget stopped
90
+ the session.
91
+ - The project list no longer prints the raw `running` of a run whose worker died before writing
92
+ `meta.json`. A row without a verdict shows what closing the run would write: `abandoned` for a
93
+ dead or foreign process, `unverified` when the end cannot be proven. The "live now" column keeps
94
+ its fresh-heartbeat policy.
95
+
96
+ ### Changed
97
+
98
+ - One module decides whether a recorded run is still live. Seven places answered that question on
99
+ their own through pid-first wrappers whose other inputs were optional — which is how the response
100
+ guard came to ask with a bare pid and the project list never to ask at all, although
101
+ `markAbandoned()` already held the exact rule. `src/home/lib/meta/run-liveness.mjs` now owns it,
102
+ and `tests/one-liveness-judge.test.mjs` fails the suite when another module asks a process
103
+ identity, re-exports one under a new name, or declares a pid-first wrapper again.
104
+ - The rows a reply carries are rendered by one function. The Retention row had been copied into five
105
+ reply functions and was still missing from the runner's failure path, so a runner failure lost it;
106
+ a sixth copy for the sandbox row would have repeated that defect.
107
+
7
108
  ## [0.6.2] - 2026-09-07
8
109
 
9
110
  ### Fixed
package/README.md CHANGED
@@ -62,7 +62,7 @@ If a global install and a clone coexist, you have two package copies. `codex-bri
62
62
  - **Every run leaves an audit folder.** The verbatim task, scope, before/after git state, events, report, verdict, and reason remain together.
63
63
  - **Zero runtime dependencies and zero build steps.** The package is plain `.mjs` on the Node.js standard library.
64
64
 
65
- The current suite contains **975 automated tests: 974 passing and 1 skipped**.
65
+ The current suite contains **1177 automated tests: 1176 passing and 1 skipped**.
66
66
 
67
67
  ## Verify
68
68
 
@@ -3,6 +3,7 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { liveRuns } from '../src/home/hooks/live-runs.mjs';
5
5
  import { readJsonFileSync } from '../src/home/lib/json-file.mjs';
6
+ import { runLiveness } from '../src/home/lib/meta/run-liveness.mjs';
6
7
  import { runsRoot } from '../src/home/lib/runner/runs-root.mjs';
7
8
 
8
9
  const record = (value) => value && typeof value === 'object' && !Array.isArray(value);
@@ -108,7 +109,7 @@ function readRun(runDir, livePaths) {
108
109
  return {
109
110
  run: path.basename(runDir),
110
111
  agent: text(facts?.agent),
111
- verdict: live ? 'running' : text(meta ? meta.status : status?.state),
112
+ verdict: live ? 'running' : text(meta ? meta.status : runLiveness({ runDir, status }).state),
112
113
  tokens: meta ? number(meta.tokens) : null,
113
114
  size: recursiveSize(runDir),
114
115
  timestamp: runTimestamp(path.basename(runDir), meta, status),
@@ -131,8 +132,9 @@ function publicRun(run) {
131
132
 
132
133
  function buildProject(projectDir) {
133
134
  const runDirs = directoryEntries(projectDir).map((entry) => path.join(projectDir, entry.name));
134
- // live-runs owns PID and status interpretation after the 2026-08-05 live probe incident;
135
- // this module only maps its result back to the corresponding inventory row.
135
+ // live-runs owns the `live` column (a fresh heartbeat) after the 2026-08-05 live probe incident.
136
+ // A row without a verdict shows what closing it would write — the judge's `abandoned` rather than
137
+ // the raw `running` a dead run keeps in status.json forever (Plan_57 D27).
136
138
  const livePaths = new Set(liveRuns(projectDir).map(({ dir }) => pathKey(dir)));
137
139
  const details = runDirs.map((runDir) => readRun(runDir, livePaths));
138
140
  // Sum what is known instead of blanking the column. One archived run without accounting used to
package/cli/stop.mjs CHANGED
@@ -2,12 +2,12 @@
2
2
  import path from 'node:path';
3
3
  import { stopCodex } from '../src/home/lib/runner/codex-cmd.mjs';
4
4
  import { git, worktreeSnapshot } from '../src/home/lib/runner/git-state.mjs';
5
+ import { runLiveness } from '../src/home/lib/meta/run-liveness.mjs';
5
6
  import {
6
7
  IDENTITY_ALIVE,
7
8
  IDENTITY_FOREIGN,
8
9
  IDENTITY_UNVERIFIED,
9
10
  processAlive,
10
- processIdentity,
11
11
  } from '../src/home/lib/process-identity.mjs';
12
12
  import { markAbandoned, readJson } from '../src/home/lib/write-meta.mjs';
13
13
  import { runsRoot } from '../src/home/lib/runner/runs-root.mjs';
@@ -72,7 +72,7 @@ export async function stop({
72
72
  return result(1, `Run ${runDir} has no valid recorded pid; it was not changed.`);
73
73
  }
74
74
 
75
- const identity = processIdentity({ runDir, status, commandRunner });
75
+ const { identity } = runLiveness({ runDir, status, commandRunner });
76
76
  if (identity === IDENTITY_UNVERIFIED || identity === IDENTITY_FOREIGN) {
77
77
  return result(
78
78
  1,
package/cli/unlock.mjs CHANGED
@@ -3,11 +3,11 @@
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { formatSilence, heartbeatAge, HEARTBEAT_STALE_MS } from '../src/home/lib/heartbeat.mjs';
6
+ import { runLiveness } from '../src/home/lib/meta/run-liveness.mjs';
6
7
  import { markAbandoned } from '../src/home/lib/meta/run-state.mjs';
7
8
  import {
8
9
  IDENTITY_ALIVE,
9
10
  IDENTITY_UNVERIFIED,
10
- processIdentity,
11
11
  } from '../src/home/lib/process-identity.mjs';
12
12
  import { git } from '../src/home/lib/runner/git-state.mjs';
13
13
  import { resolveProjectRunsDir } from '../src/home/lib/runner/project-dir.mjs';
@@ -144,7 +144,7 @@ function inspectRuns(project, options) {
144
144
  age: runAge(status.started_at, now),
145
145
  silence: formatSilence(silenceAge),
146
146
  silenceAge,
147
- identity: processIdentity(identityOptions(dir, status, options, now)),
147
+ identity: runLiveness(identityOptions(dir, status, options, now)).identity,
148
148
  state: status.state,
149
149
  });
150
150
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lyupro/codex-bridge",
3
- "version": "0.6.2",
4
- "description": "Claude Code-to-Codex delegation with 6 guards, 975 tests, zero runtime dependencies, and zero build steps.",
3
+ "version": "0.6.4",
4
+ "description": "Claude Code-to-Codex delegation with 6 guards, 1177 tests, zero runtime dependencies, and zero build steps.",
5
5
  "keywords": [
6
6
  "claude-code",
7
7
  "codex",
@@ -9,34 +9,17 @@
9
9
  *
10
10
  * The heartbeat is required here and deliberately NOT in meta/run-state.mjs: releasing a lock
11
11
  * early costs an operator one knowing edit, while closing a record early makes a second writer
12
- * of its meta.json. See the comment above pidAlive() there.
12
+ * of its meta.json. See the comment above markAbandoned() in meta/run-state.mjs.
13
13
  */
14
14
  import fs from 'node:fs';
15
15
  import path from 'node:path';
16
16
  import { heartbeatAge, isHeartbeatFresh } from '../lib/heartbeat.mjs';
17
17
  import { readJsonFileSync } from '../lib/json-file.mjs';
18
- import {
19
- IDENTITY_ALIVE,
20
- IDENTITY_DEAD,
21
- IDENTITY_FOREIGN,
22
- processIdentity,
23
- } from '../lib/process-identity.mjs';
18
+ import { runLiveness } from '../lib/meta/run-liveness.mjs';
19
+ import { IDENTITY_ALIVE } from '../lib/process-identity.mjs';
24
20
 
25
21
  export const RECENT_RUN_MAX_AGE_MS = 24 * 60 * 60 * 1_000;
26
22
 
27
- /**
28
- * The hook requires pid judgment plus a fresh heartbeat. Identity uncertainty stays live here
29
- * (fail-open), while the identity module deliberately treats a missing heartbeat as unverified;
30
- * isHeartbeatFresh() keeps its pre-Plan_20 missing-file compatibility for this separate question.
31
- */
32
- // Plan_31 needs confirmed process identity before warning about paid work that TaskStop leaves
33
- // behind; existing lock/reply guards retain the fail-open default for uncertain identities.
34
- export const isPidAlive = (pid, runDir, status = {}, options = {}) => {
35
- const identity = processIdentity({ runDir, status: { ...status, pid } });
36
- if (options.requireConfirmedIdentity === true) return identity === IDENTITY_ALIVE;
37
- return identity !== IDENTITY_DEAD && identity !== IDENTITY_FOREIGN;
38
- };
39
-
40
23
  /**
41
24
  * Match the package's repository comparison without resolving symlinks: Windows realpath adds
42
25
  * `\\?\\` and UNC spellings, turning equal paths into unequal ones. A null result is uncertainty,
@@ -58,9 +41,24 @@ function isNonEmptyString(value) {
58
41
  return typeof value === 'string' && Boolean(value.trim());
59
42
  }
60
43
 
44
+ /**
45
+ * The hook requires pid judgment plus a fresh heartbeat. Identity uncertainty stays live here
46
+ * (fail-open), while the identity module deliberately treats a missing heartbeat as unverified;
47
+ * isHeartbeatFresh() keeps its pre-Plan_20 missing-file compatibility for this separate question.
48
+ */
61
49
  function recognizedStatus(runDir, status, options) {
62
50
  const heartbeatFresh = isHeartbeatFresh(runDir)
63
51
  && (options.requireConfirmedIdentity !== true || heartbeatAge(runDir) !== null);
52
+ // Judged last: an identity probe can spawn PowerShell, and every hook call scans every project,
53
+ // so a record already ruled out by its fields or a stale heartbeat must never pay for one.
54
+ const judged = () => {
55
+ const liveness = runLiveness({ runDir, status });
56
+ // Plan_31 needs confirmed process identity before warning about paid work that TaskStop leaves
57
+ // behind; existing lock/reply guards retain the fail-open default for uncertain identities.
58
+ return options.requireConfirmedIdentity === true
59
+ ? liveness.identity === IDENTITY_ALIVE
60
+ : liveness.processMayBeAlive === true;
61
+ };
64
62
  return Boolean(status)
65
63
  && typeof status === 'object'
66
64
  && !Array.isArray(status)
@@ -70,7 +68,7 @@ function recognizedStatus(runDir, status, options) {
70
68
  && isNonEmptyString(status.slug)
71
69
  && isNonEmptyString(status.repo)
72
70
  && heartbeatFresh
73
- && isPidAlive(status.pid, runDir, status, options);
71
+ && judged();
74
72
  }
75
73
 
76
74
  function readStatus(runDir) {
@@ -33,9 +33,10 @@ import { AGENTS } from '../lib/agents.mjs';
33
33
  import { runOrderMismatch, transcriptOrderId } from '../lib/dispatcher-order.mjs';
34
34
  import { recognizeHostRefusal } from '../lib/host-refusal.mjs';
35
35
  import { readJsonFileSync } from '../lib/json-file.mjs';
36
+ import { runLiveness } from '../lib/meta/run-liveness.mjs';
36
37
  import { resolveProjectRunsDir } from '../lib/runner/project-dir.mjs';
37
38
  import { runsRoot } from '../lib/runner/runs-root.mjs';
38
- import { isPidAlive, liveRuns, normalizePath, recentRuns } from './live-runs.mjs';
39
+ import { liveRuns, normalizePath, recentRuns } from './live-runs.mjs';
39
40
  import { FORM, MAX_STATE_BLOCKS, STATE, takeTry } from './guard-tries.mjs';
40
41
  import { parseReply } from './reply-parser.mjs';
41
42
  import {
@@ -233,7 +234,9 @@ if (fs.existsSync(statusPath)) {
233
234
  }
234
235
 
235
236
  if (runStatus?.state === 'running') {
236
- if (isPidAlive(runStatus.pid)) {
237
+ // Plan_57 D5/D27: a bare pid mistook a reused foreign process for this run's worker.
238
+ // The shared judge uses run identity and keeps unverified processes live (fail open).
239
+ if (runLiveness({ runDir, status: runStatus }).processMayBeAlive) {
237
240
  blockState(
238
241
  liveRunReason,
239
242
  liveRunStop(MAX_STATE_BLOCKS, runStatus, runDir),