@lyupro/codex-bridge 0.6.7 → 0.6.9

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 (101) hide show
  1. package/CHANGELOG.md +148 -0
  2. package/README.md +10 -6
  3. package/bin/codex-bridge.mjs +35 -172
  4. package/cli/command-help.mjs +49 -0
  5. package/cli/command-registry.mjs +284 -0
  6. package/cli/copy.mjs +42 -5
  7. package/cli/dispatcher-contract-record.mjs +21 -11
  8. package/cli/dispatcher-contract.mjs +6 -5
  9. package/cli/doctor-host-side.mjs +110 -0
  10. package/cli/doctor-installation.mjs +111 -111
  11. package/cli/doctor.mjs +35 -7
  12. package/cli/handback-witness-check.mjs +10 -18
  13. package/cli/home-inspection.mjs +122 -0
  14. package/cli/home-plan-execute.mjs +140 -0
  15. package/cli/home-removal-plan.mjs +61 -0
  16. package/cli/hook-recognizer.mjs +78 -0
  17. package/cli/host-contract.mjs +36 -21
  18. package/cli/host-inspection.mjs +156 -0
  19. package/cli/host-removal.mjs +91 -0
  20. package/cli/image-evidence.mjs +44 -0
  21. package/cli/install-owners.mjs +244 -0
  22. package/cli/install-record.mjs +71 -9
  23. package/cli/install.mjs +101 -77
  24. package/cli/inventory-removal.mjs +84 -0
  25. package/cli/inventory-transition.mjs +66 -0
  26. package/cli/lifecycle-lock-clear.mjs +104 -0
  27. package/cli/lifecycle-lock-inspect.mjs +91 -0
  28. package/cli/lifecycle-lock.mjs +335 -0
  29. package/cli/lifecycle-transaction.mjs +57 -0
  30. package/cli/link-segments.mjs +45 -0
  31. package/cli/model-set.mjs +4 -3
  32. package/cli/model-speed.mjs +9 -2
  33. package/cli/model.mjs +5 -3
  34. package/cli/owner-record.mjs +40 -0
  35. package/cli/owner-sync-apply.mjs +108 -0
  36. package/cli/owner-sync.mjs +115 -0
  37. package/cli/probe-contract.mjs +26 -10
  38. package/cli/probe-target.mjs +33 -0
  39. package/cli/projects.mjs +5 -2
  40. package/cli/prune-args.mjs +9 -4
  41. package/cli/prune.mjs +23 -72
  42. package/cli/purge-live-runs.mjs +98 -0
  43. package/cli/purge-preflight.mjs +103 -0
  44. package/cli/read.mjs +2 -1
  45. package/cli/record-removal.mjs +43 -0
  46. package/cli/removal-outcomes.mjs +115 -0
  47. package/cli/removal-plan.mjs +187 -0
  48. package/cli/remove-layout.mjs +40 -1
  49. package/cli/rules-owners.mjs +4 -2
  50. package/cli/session-hosts.mjs +51 -0
  51. package/cli/terminal-question.mjs +41 -0
  52. package/cli/uninstall-purge.mjs +139 -0
  53. package/cli/uninstall-rules.mjs +72 -0
  54. package/cli/uninstall.mjs +208 -126
  55. package/cli/unlock-lifecycle.mjs +102 -0
  56. package/cli/unlock.mjs +7 -4
  57. package/cli/update.mjs +102 -27
  58. package/package.json +4 -3
  59. package/scripts/home-write-audit.mjs +286 -0
  60. package/scripts/home-write-inventory.mjs +49 -0
  61. package/scripts/home-write-lexer.mjs +182 -0
  62. package/scripts/isolated-roots.mjs +6 -1
  63. package/scripts/open-work/check.mjs +180 -0
  64. package/scripts/open-work/live-thresholds.mjs +155 -0
  65. package/scripts/open-work/register.mjs +56 -0
  66. package/scripts/root-guard/root-entries.mjs +15 -0
  67. package/scripts/run-tests.mjs +33 -1
  68. package/src/agents/codex-advisor.md +5 -2
  69. package/src/home/conventions.md +9 -0
  70. package/src/home/hooks/dispatcher-gate.mjs +15 -2
  71. package/src/home/hooks/guard-tries.mjs +6 -4
  72. package/src/home/hooks/order-gate.mjs +2 -8
  73. package/src/home/hooks/reply-guard.mjs +24 -9
  74. package/src/home/hooks/worktree-witness.mjs +6 -6
  75. package/src/home/lib/agents.mjs +1 -1
  76. package/src/home/lib/atomic-json.mjs +24 -6
  77. package/src/home/lib/config-edit.mjs +25 -10
  78. package/src/home/lib/dispatcher-state.mjs +25 -10
  79. package/src/home/lib/file-lock.mjs +41 -10
  80. package/src/home/lib/handback-witness.mjs +33 -25
  81. package/src/home/lib/home-registry.mjs +211 -0
  82. package/src/home/lib/home-write.mjs +189 -0
  83. package/src/home/lib/hook-diagnostics.mjs +20 -0
  84. package/src/home/lib/host-observations.mjs +60 -0
  85. package/src/home/lib/host-version.mjs +27 -0
  86. package/src/home/lib/meta/advice-status.mjs +2 -2
  87. package/src/home/lib/meta/advice-verdict.mjs +30 -1
  88. package/src/home/lib/meta/events.mjs +2 -1
  89. package/src/home/lib/meta/paths.mjs +24 -24
  90. package/src/home/lib/meta/run-snapshots.mjs +29 -0
  91. package/src/home/lib/meta/snapshot-format.mjs +96 -0
  92. package/src/home/lib/meta/verdict.mjs +14 -16
  93. package/src/home/lib/run-config.mjs +10 -1
  94. package/src/home/lib/runner/git-paths.mjs +74 -0
  95. package/src/home/lib/runner/git-state.mjs +38 -34
  96. package/src/home/lib/runner/launcher.mjs +2 -2
  97. package/src/home/lib/runner/preflight.mjs +7 -0
  98. package/src/home/lib/runner/prompts.mjs +35 -2
  99. package/src/home/lib/runner/schemas.mjs +4 -1
  100. package/src/home/lib/runner/scope-check.mjs +29 -20
  101. package/src/home/lib/runner/worker.mjs +3 -3
@@ -83,8 +83,9 @@ codex-bridge run --agent codex-advisor --repo "<repository-path or .>" --phase "
83
83
  ```
84
84
 
85
85
  That is one command line. Add only the bare `--continue` when the task text contains a line
86
- beginning with `continue:`; it has no value. The runner accepts only `1/true/yes/0/false/no` as a
87
- grant value in the task text, so a malformed grant is passed through for the runner to refuse. If
86
+ beginning with `continue:`; the flag itself takes no value (the runner accepts only
87
+ `1/true/yes/0/false/no` there). The grant line names a run folder and a reason, and the runner parses
88
+ it, so a malformed grant is passed through for the runner to refuse. If
88
89
  there is no grant line, do not add the flag. Add `--effort "<value>"` only if the orchestrator
89
90
  named a depth, and only with one of `none|low|medium|high|xhigh|max`. Never add free text or any
90
91
  flags beyond those two conditional flags.
@@ -158,6 +159,8 @@ verbatim; compose nothing.
158
159
  The run folder contains its status and artifacts. An abandoned run is not a reason to start over
159
160
  yourself: the orchestrator decides whether to continue, and without the `--continue` it issued,
160
161
  the runner will reject that continuation itself.
162
+ The orchestrator repeats a failed scope under a NEW order id, never with `continue:` or `--continue`:
163
+ the runner refuses a continued scope, because it would spend the continuation that advise needs.
161
164
 
162
165
  ## Codex is unavailable
163
166
 
@@ -42,3 +42,12 @@ boundary is needed.
42
42
  The 2026-08-05 step 2b acceptance found the abandoned-run check below the commit check. Test the
43
43
  ordering invariant itself, and leave the incident in the comment so a later cleanup does not
44
44
  silently undo it.
45
+
46
+ ## Failing tests are evidence - Plan_73 B3 fixture incident
47
+
48
+ - Never change a fixture or an expectation only to make a failing test pass.
49
+ - When an existing test fails for a reason the task does not describe, leave it failing and name
50
+ it under deviations.
51
+
52
+ The 2026-09-30 Plan_73 B3 run rewrote a witness fixture so that a snapshot codec defect stopped
53
+ showing; that defect would have failed every build started on a clean tree.
@@ -17,7 +17,8 @@ import {
17
17
  readDispatcherState,
18
18
  updateDispatcherState,
19
19
  } from '../lib/dispatcher-state.mjs';
20
- import { hostSdkVersion, recordHandbackWitness } from '../lib/handback-witness.mjs';
20
+ import { recordHandbackWitness, witnessHostVersion } from '../lib/handback-witness.mjs';
21
+ import { observeSessionHost } from '../lib/host-observations.mjs';
21
22
 
22
23
  function emit(decision, toolInput) {
23
24
  const hookSpecificOutput = {
@@ -43,6 +44,18 @@ async function main() {
43
44
  return;
44
45
  }
45
46
 
47
+ if (payload?.hook_event_name === 'PreToolUse'
48
+ && typeof payload.session_id === 'string' && payload.session_id.length > 0
49
+ && typeof payload.transcript_path === 'string' && payload.transcript_path.length > 0) {
50
+ try {
51
+ await observeSessionHost({
52
+ stateDir: BRAND_STATE_DIR,
53
+ sessionId: payload.session_id,
54
+ transcriptPath: payload.transcript_path,
55
+ });
56
+ } catch {}
57
+ }
58
+
46
59
  if (!payload || !Object.prototype.hasOwnProperty.call(AGENTS, payload.agent_type)
47
60
  || typeof payload.session_id !== 'string' || payload.session_id.length === 0
48
61
  || typeof payload.agent_id !== 'string' || payload.agent_id.length === 0) return;
@@ -87,7 +100,7 @@ async function main() {
87
100
  await recordHandbackWitness({
88
101
  stateDir: BRAND_STATE_DIR,
89
102
  kind: 'seen',
90
- sdkVersion: hostSdkVersion(),
103
+ hostVersion: await witnessHostVersion(payload),
91
104
  });
92
105
  } catch {}
93
106
  try {
@@ -1,6 +1,6 @@
1
- import fs from 'node:fs';
2
1
  import path from 'node:path';
3
- import { BRAND_STATE_DIR } from '../lib/brand-home.mjs';
2
+ import { BRAND_HOME, BRAND_STATE_DIR } from '../lib/brand-home.mjs';
3
+ import { createHomeWriter } from '../lib/home-write.mjs';
4
4
  import { readJsonFileSync } from '../lib/json-file.mjs';
5
5
 
6
6
  /**
@@ -49,8 +49,10 @@ export function takeTry(agentId, kind, maxBlocks = kind === STATE ? MAX_STATE_BL
49
49
  const ids = Object.keys(seen);
50
50
  if (ids.length > 200) ids.slice(0, ids.length - 200).forEach((id) => delete seen[id]);
51
51
  try {
52
- fs.mkdirSync(path.dirname(BLOCKED_FILE), { recursive: true });
53
- fs.writeFileSync(BLOCKED_FILE, `${JSON.stringify(seen)}\n`);
52
+ // Plan_65 B4: the budget is the registered guard-tries artifact, so purge can name and remove it.
53
+ const writer = createHomeWriter({ root: BRAND_HOME.root });
54
+ writer.mkdirSync('guard-tries', path.dirname(BLOCKED_FILE), { recursive: true });
55
+ writer.writeFileSync('guard-tries', BLOCKED_FILE, `${JSON.stringify(seen)}\n`);
54
56
  } catch {
55
57
  return 'untracked';
56
58
  }
@@ -14,7 +14,7 @@
14
14
  import fs from 'node:fs';
15
15
  import path from 'node:path';
16
16
  import { AGENTS } from '../lib/agents.mjs';
17
- import { BRAND_STATE_DIR } from '../lib/brand-home.mjs';
17
+ import { recordHookDiagnostic } from '../lib/hook-diagnostics.mjs';
18
18
  import { readJsonFileSync } from '../lib/json-file.mjs';
19
19
  import {
20
20
  diagnoseInput,
@@ -30,7 +30,6 @@ import { resolveProjectRunsDir } from '../lib/runner/project-dir.mjs';
30
30
  import { runsRoot } from '../lib/runner/runs-root.mjs';
31
31
  import { parseTaskDocument } from '../lib/runner/task-file.mjs';
32
32
 
33
- const DIAGNOSTICS_DIR = path.join(BRAND_STATE_DIR, 'diagnostics');
34
33
  const GUARDED = new Set(Object.keys(AGENTS));
35
34
  /**
36
35
  * Both spellings of the subagent-launching tool, from the same list the installer builds its
@@ -58,12 +57,7 @@ try {
58
57
  pass();
59
58
  }
60
59
 
61
- try {
62
- fs.mkdirSync(DIAGNOSTICS_DIR, { recursive: true });
63
- fs.writeFileSync(path.join(DIAGNOSTICS_DIR, 'order-gate.last.json'), `${JSON.stringify(input, null, 2)}\n`);
64
- } catch {
65
- // Diagnostics are a convenience, never a reason to fail the turn.
66
- }
60
+ recordHookDiagnostic('order-gate', input);
67
61
 
68
62
  if (!input || typeof input !== 'object' || Array.isArray(input)) pass();
69
63
  if (!SUBAGENT_TOOL_NAMES.has(input.tool_name)) pass();
@@ -22,6 +22,7 @@
22
22
  * argument about EXTERNAL run state cannot be argued away by the model and ends the turn
23
23
  * with `continue: false` instead of a silent pass. See MAX_FORM_BLOCKS / MAX_STATE_BLOCKS.
24
24
  * In the handback era, SubagentHandback delivers the caller-visible answer before SubagentStop.
25
+ * Alarms name the host by its transcript version: on 2026-09-25 the SDK variable proved inherited.
25
26
  * Plan_62 makes this hook yield after delivery and audit every transcript tool call against gate receipts.
26
27
  *
27
28
  * Input is JSON on stdin. The fields used here (`agent_type`, `last_assistant_message`)
@@ -32,12 +33,13 @@ import fs from 'node:fs';
32
33
  import path from 'node:path';
33
34
  import { AGENTS } from '../lib/agents.mjs';
34
35
  import { BRAND_STATE_DIR } from '../lib/brand-home.mjs';
36
+ import { recordHookDiagnostic } from '../lib/hook-diagnostics.mjs';
35
37
  import { runOrderMismatch, transcriptOrderId } from '../lib/dispatcher-order.mjs';
36
38
  import { decideDispatcherStop, transcriptToolUses } from '../lib/dispatcher-stop.mjs';
37
39
  import { recognizeHostRefusal } from '../lib/host-refusal.mjs';
38
40
  import { readJsonFileSync } from '../lib/json-file.mjs';
39
41
  import { readDispatcherState, updateDispatcherState } from '../lib/dispatcher-state.mjs';
40
- import { hostSdkVersion, recordHandbackWitness } from '../lib/handback-witness.mjs';
42
+ import { recordHandbackWitness, witnessHostVersion } from '../lib/handback-witness.mjs';
41
43
  import { runLiveness } from '../lib/meta/run-liveness.mjs';
42
44
  import { resolveProjectRunsDir } from '../lib/runner/project-dir.mjs';
43
45
  import { runsRoot } from '../lib/runner/runs-root.mjs';
@@ -110,14 +112,27 @@ try {
110
112
  pass();
111
113
  }
112
114
 
113
- try {
114
- const diagnosticsDir = path.join(BRAND_STATE_DIR, 'diagnostics');
115
- fs.mkdirSync(diagnosticsDir, { recursive: true });
116
- fs.writeFileSync(path.join(diagnosticsDir, 'reply-guard.last.json'), `${JSON.stringify(input, null, 2)}\n`);
117
- } catch {
118
- // Diagnostics are a convenience, never a reason to fail the turn.
119
- }
115
+ recordHookDiagnostic('reply-guard', input);
120
116
 
117
+ // A host that stops sending agent_type would let every dispatcher past the gate and this
118
+ // audit without a word (Plan_66 D2 (c)); say so instead of passing silently.
119
+ if (typeof input.agent_id === 'string' && input.agent_id.length > 0
120
+ && !(typeof input.agent_type === 'string' && input.agent_type.length > 0)) {
121
+ try {
122
+ await recordHandbackWitness({
123
+ stateDir: BRAND_STATE_DIR,
124
+ kind: 'alarm',
125
+ hostVersion: await witnessHostVersion(input),
126
+ detail: `host omitted agent_type for agent ${input.agent_id}`,
127
+ });
128
+ } catch {
129
+ // Plan_66 H2: the 2026-09-17..24 handback break showed host contract loss must alarm, but never block work.
130
+ }
131
+ process.stdout.write(JSON.stringify({
132
+ systemMessage: 'codex-bridge: the host did not report agent_type for a subagent — the dispatcher gate cannot recognise dispatchers, so their answers are unchecked; run codex-bridge doctor.',
133
+ }));
134
+ process.exit(0);
135
+ }
121
136
  // Only the Codex dispatchers have this contract. Every other agent is none of our
122
137
  // business — an earlier version inferred the agent from transcript text and blocked an
123
138
  // unrelated one, which cost a re-answer for nothing.
@@ -144,7 +159,7 @@ try {
144
159
  await recordHandbackWitness({
145
160
  stateDir: BRAND_STATE_DIR,
146
161
  kind: 'alarm',
147
- sdkVersion: hostSdkVersion(),
162
+ hostVersion: await witnessHostVersion(input),
148
163
  detail: `${input.agent_type} ${input.agent_id}: ${names} outside the dispatcher gate`,
149
164
  });
150
165
  }
@@ -15,7 +15,7 @@ import path from 'node:path';
15
15
  import { SHELL_TOOLS } from '../lib/hook-definitions.mjs';
16
16
  import { parseJsonText } from '../lib/json-file.mjs';
17
17
  import { splitRunChanges } from '../lib/meta/environment.mjs';
18
- import { changedPaths } from '../lib/meta/paths.mjs';
18
+ import { compareSnapshots } from '../lib/meta/snapshot-format.mjs';
19
19
  import { outOfScope } from '../lib/meta/verdict.mjs';
20
20
  import { git, worktreeSnapshot } from '../lib/runner/git-state.mjs';
21
21
  import { runsRoot } from '../lib/runner/runs-root.mjs';
@@ -64,16 +64,16 @@ if (beforeText === null || scopeText === null) pass();
64
64
 
65
65
  let outside;
66
66
  try {
67
- // changedPaths tolerates malformed rows; a hook must instead fail open on a damaged baseline.
68
- if (beforeText.split(/\r?\n/).filter(Boolean).some((row) =>
69
- !/^(?:\d+\t\d+|-\t-|U\t\d+)\t\S.*$/.test(row))) pass();
70
67
  const patterns = scopeText.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
71
68
  if (!patterns.length) pass();
72
69
  // The shared snapshot returns empty output on git errors; do not call that a restored tree.
73
70
  const repository = git(status.repo, ['rev-parse', '--is-inside-work-tree']);
74
71
  if (repository.error || repository.status !== 0 || repository.stdout?.trim() !== 'true') pass();
75
- const current = worktreeSnapshot(status.repo);
76
- const { work } = splitRunChanges(dir, changedPaths(beforeText, current));
72
+ // Plan_73 / 2026-09-30: a damaged or incompatible baseline proves neither a clean
73
+ // tree nor a stray edit. The advisory witness stays silent when the codec refuses it.
74
+ const comparison = compareSnapshots(beforeText, worktreeSnapshot(status.repo));
75
+ if (!comparison.ok) pass();
76
+ const { work } = splitRunChanges(dir, comparison.changed);
77
77
  outside = outOfScope(work, patterns);
78
78
  } catch {
79
79
  pass();
@@ -12,7 +12,7 @@ export const AGENTS = {
12
12
  'codex-build': { role: 'build', budget: 25, writes: true, result: 'result.json' },
13
13
  'codex-review': { role: 'review', budget: 20, writes: false, result: 'review.json' },
14
14
  // Plan_59: design advice needs a bounded scope pass before the continued decision pass.
15
- 'codex-advisor': { role: 'advisor', budget: { scope: 5, advise: 15 }, writes: false, result: 'result.json' },
15
+ 'codex-advisor': { role: 'advisor', budget: { scope: 10, advise: 25 }, writes: false, result: 'result.json' },
16
16
  };
17
17
 
18
18
  export const agentRole = (agent) => AGENTS[agent]?.role;
@@ -6,16 +6,34 @@
6
6
  * (`config-edit.mjs` keeps its own compare-and-swap publish for that reason).
7
7
  */
8
8
  import { randomUUID } from 'node:crypto';
9
- import fs from 'node:fs';
10
9
  import path from 'node:path';
11
10
 
12
- export function writeJsonAtomic(file, record) {
13
- fs.mkdirSync(path.dirname(file), { recursive: true });
11
+ function removeIfPresent(remove, temporary) {
12
+ try {
13
+ remove(temporary);
14
+ } catch (error) {
15
+ if (error?.code !== 'ENOENT') throw error;
16
+ }
17
+ }
18
+
19
+ function publishJsonAtomic(file, record, operations) {
14
20
  const temporary = `${file}.${randomUUID()}.tmp`;
15
21
  try {
16
- fs.writeFileSync(temporary, `${JSON.stringify(record, null, 2)}\n`, { flag: 'wx' });
17
- fs.renameSync(temporary, file);
22
+ operations.mkdir(path.dirname(file), { recursive: true });
23
+ operations.write(temporary, `${JSON.stringify(record, null, 2)}\n`, { flag: 'wx' });
24
+ operations.rename(temporary, file);
18
25
  } finally {
19
- fs.rmSync(temporary, { force: true });
26
+ removeIfPresent(operations.unlink, temporary);
20
27
  }
21
28
  }
29
+
30
+ export function writeHomeJsonAtomic(writer, id, file, record) {
31
+ // Plan_65 B2: `state/` is shared by artifacts, so validate the file before mkdir can create it.
32
+ writer.assertArtifact(id, file);
33
+ return publishJsonAtomic(file, record, {
34
+ mkdir(directory, options) { return writer.mkdirSync(id, directory, options); },
35
+ write(temporary, contents, options) { return writer.writeFileSync(id, temporary, contents, options); },
36
+ rename(temporary, target) { return writer.renameSync(id, temporary, target); },
37
+ unlink(temporary) { return writer.unlinkSync(id, temporary); },
38
+ });
39
+ }
@@ -13,7 +13,8 @@ import { randomUUID } from 'node:crypto';
13
13
  import { BRAND_CONFIG_PATH } from './brand-home.mjs';
14
14
  import { parseJsonText } from './json-file.mjs';
15
15
  import { validateRunConfig } from './config-validate.mjs';
16
- import { withFileLock } from './file-lock.mjs';
16
+ import { createHomeWriter } from './home-write.mjs';
17
+ import { withHomeFileLock } from './file-lock.mjs';
17
18
 
18
19
  function replaceValue(file, raw, key, value) {
19
20
  // The shared reader has already checked JSON syntax; tokens locate top-level value spans.
@@ -72,6 +73,14 @@ async function readBytes(file) {
72
73
  }
73
74
  }
74
75
 
76
+ async function removeTemporary(writer, temporary) {
77
+ try {
78
+ await writer.unlink('config', temporary);
79
+ } catch (error) {
80
+ if (error.code !== 'ENOENT') throw error;
81
+ }
82
+ }
83
+
75
84
  function readBytesSync(file) {
76
85
  try {
77
86
  return fsSync.readFileSync(file);
@@ -82,15 +91,17 @@ function readBytesSync(file) {
82
91
  }
83
92
 
84
93
  export async function editRunConfig(key, transform, file = BRAND_CONFIG_PATH) {
94
+ const directory = path.dirname(path.resolve(file));
95
+ const writer = createHomeWriter({ root: directory });
96
+ writer.assertArtifact('config', file);
85
97
  const reset = key !== null && typeof key === 'object' && !Array.isArray(key)
86
98
  && key.reset === true && Object.keys(key).length === 1 && transform === undefined;
87
99
  if (!reset && (typeof key !== 'string' || !key || typeof transform !== 'function')) {
88
100
  throw new Error('Config edit requires a non-empty string key and a transformer function or { reset: true }');
89
101
  }
90
- const directory = path.dirname(file);
91
- const created = await fs.mkdir(directory, { recursive: true });
102
+ const created = await writer.mkdir('config', directory, { recursive: true });
92
103
  try {
93
- return await withFileLock(`${file}.lock`, async () => {
104
+ return await withHomeFileLock(writer, 'config', `${file}.lock`, async () => {
94
105
  let collision;
95
106
  for (let attempt = 0; attempt < 3; attempt += 1) {
96
107
  const original = await readBytes(file);
@@ -109,9 +120,9 @@ export async function editRunConfig(key, transform, file = BRAND_CONFIG_PATH) {
109
120
  }
110
121
  // Validate the exact bytes to be persisted, before even creating a temporary file (D27).
111
122
  const config = validateRunConfig(file, parseJsonText(file, text));
112
- await fs.mkdir(path.dirname(file), { recursive: true });
123
+ await writer.mkdir('config', directory, { recursive: true });
113
124
  const temporary = path.join(path.dirname(file), `.${path.basename(file)}.${randomUUID()}.tmp`);
114
- const handle = await fs.open(temporary, 'wx');
125
+ const handle = await writer.open('config', temporary, 'wx');
115
126
  try {
116
127
  await handle.writeFile(text, 'utf8');
117
128
  await handle.close();
@@ -121,14 +132,14 @@ export async function editRunConfig(key, transform, file = BRAND_CONFIG_PATH) {
121
132
  // suspension point between the two, so a competing edit is seen by whoever compares second.
122
133
  const current = readBytesSync(file);
123
134
  if (original === null ? current !== null : current === null || !original.equals(current)) {
124
- await fs.rm(temporary, { force: true });
135
+ await removeTemporary(writer, temporary);
125
136
  continue;
126
137
  }
127
- fsSync.renameSync(temporary, file);
138
+ writer.renameSync('config', temporary, file);
128
139
  return config;
129
140
  } catch (error) {
130
141
  await handle.close().catch(() => {});
131
- await fs.rm(temporary, { force: true });
142
+ await removeTemporary(writer, temporary);
132
143
  // Windows refuses the rename outright when another process is publishing onto the same name
133
144
  // at that instant: two concurrent `model set` calls returned EPERM here on a live probe, and
134
145
  // one operator edit was lost with the file system's wording instead of an answer. A collision
@@ -149,9 +160,13 @@ export async function editRunConfig(key, transform, file = BRAND_CONFIG_PATH) {
149
160
  if (created) {
150
161
  // Windows mkdir returns a namespaced path; compare that form on both sides of the boundary.
151
162
  const boundary = path.toNamespacedPath(path.resolve(created));
163
+ const homeRoot = path.toNamespacedPath(path.resolve(directory));
152
164
  for (let current = path.toNamespacedPath(path.resolve(directory));
153
165
  current === boundary || current.startsWith(`${boundary}${path.sep}`); current = path.dirname(current)) {
154
- try { await fs.rmdir(current); } catch { break; }
166
+ try {
167
+ if (current === homeRoot) writer.rmdirSync('config', directory);
168
+ else await fs.rmdir(current);
169
+ } catch { break; }
155
170
  }
156
171
  }
157
172
  throw error;
@@ -2,9 +2,14 @@
2
2
  import { createHash } from 'node:crypto';
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
- import { writeJsonAtomic } from './atomic-json.mjs';
5
+ import { writeHomeJsonAtomic } from './atomic-json.mjs';
6
6
  import { parseJsonText } from './json-file.mjs';
7
- import { withFileLock } from './file-lock.mjs';
7
+ import { withHomeFileLock } from './file-lock.mjs';
8
+ import { stateDirWriter } from './home-write.mjs';
9
+
10
+ // Plan_65 B5: every write goes through the registry id so purge accounts for records, locks and
11
+ // temporaries alike.
12
+ const ARTIFACT = 'dispatcher-state';
8
13
 
9
14
  function assertIdentity(sessionId, agentId) {
10
15
  if (typeof sessionId !== 'string' || sessionId.length === 0
@@ -55,7 +60,8 @@ export async function updateDispatcherState(ids, mutate) {
55
60
  if (typeof mutate !== 'function') throw new TypeError('mutate must be a function');
56
61
  const file = dispatcherStatePath(ids);
57
62
  const { sessionId, agentId } = ids;
58
- return withFileLock(`${file}.lock`, async () => {
63
+ const writer = stateDirWriter(ids.stateDir);
64
+ return withHomeFileLock(writer, ARTIFACT, `${file}.lock`, async () => {
59
65
  let current = readDispatcherState(ids);
60
66
  if (current === null) {
61
67
  current = { sessionId, agentId, createdAt: timestamp(clockValue(ids)) };
@@ -71,7 +77,7 @@ export async function updateDispatcherState(ids, mutate) {
71
77
  agentId,
72
78
  updatedAt: timestamp(clockValue(ids)),
73
79
  };
74
- writeJsonAtomic(file, record);
80
+ writeHomeJsonAtomic(writer, ARTIFACT, file, record);
75
81
  return record;
76
82
  }, { description: 'dispatcher state' });
77
83
  }
@@ -86,11 +92,19 @@ export function pruneDispatcherStates({ stateDir, olderThanMs = 7 * 24 * 3600 *
86
92
  throw error;
87
93
  }
88
94
 
95
+ const writer = stateDirWriter(stateDir);
89
96
  const cutoff = now - olderThanMs;
90
97
  let removed = 0;
91
98
  for (const entry of entries) {
92
99
  if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
93
100
  const file = path.join(directory, entry.name);
101
+ // Only the registry's 32-hex records are ours to prune; any other name here is kept (Plan_65 D3).
102
+ try {
103
+ writer.assertArtifact(ARTIFACT, file);
104
+ } catch (error) {
105
+ if (error.code === 'EHOMEREGISTRY') continue;
106
+ throw error;
107
+ }
94
108
  let old;
95
109
  try {
96
110
  old = fs.statSync(file).mtimeMs < cutoff;
@@ -99,13 +113,14 @@ export function pruneDispatcherStates({ stateDir, olderThanMs = 7 * 24 * 3600 *
99
113
  throw error;
100
114
  }
101
115
  if (!old) continue;
102
- try {
103
- fs.unlinkSync(file);
104
- removed += 1;
105
- } catch (error) {
106
- if (error.code !== 'ENOENT') throw error;
116
+ for (const target of [file, `${file}.lock`]) {
117
+ try {
118
+ writer.unlinkSync(ARTIFACT, target);
119
+ if (target === file) removed += 1;
120
+ } catch (error) {
121
+ if (error.code !== 'ENOENT') throw error;
122
+ }
107
123
  }
108
- fs.rmSync(`${file}.lock`, { force: true });
109
124
  }
110
125
  return removed;
111
126
  }
@@ -15,11 +15,11 @@ function waitForLock(delayMs) {
15
15
  * 2026-08-04). An owner update takes milliseconds, so a lock older than the staleness window
16
16
  * belongs to a process that is gone.
17
17
  */
18
- async function dropStaleLock(lockPath, staleMs) {
18
+ async function dropStaleLock(lockPath, staleMs, remove) {
19
19
  try {
20
20
  const { mtimeMs } = await fs.stat(lockPath);
21
21
  if (Date.now() - mtimeMs < staleMs) return false;
22
- await fs.rm(lockPath, { force: true });
22
+ await remove(lockPath);
23
23
  return true;
24
24
  } catch {
25
25
  // Vanished or unreadable — let the next open() decide rather than guess here.
@@ -42,17 +42,17 @@ export function isLockTaken(err) {
42
42
  return LOCK_TAKEN_CODES.has(err?.code);
43
43
  }
44
44
 
45
- async function acquireFileLock(lockPath, { retries, delayMs, staleMs, description }) {
46
- await fs.mkdir(path.dirname(lockPath), { recursive: true });
45
+ async function acquireFileLock(lockPath, { retries, delayMs, staleMs, description }, operations) {
46
+ await operations.mkdir(path.dirname(lockPath), { recursive: true });
47
47
  let lastTaken;
48
48
  for (let attempt = 0; attempt < retries; attempt += 1) {
49
49
  try {
50
- const handle = await fs.open(lockPath, 'wx');
51
- return { handle, lockPath };
50
+ const handle = await operations.open(lockPath);
51
+ return { handle, lockPath, remove: operations.remove };
52
52
  } catch (err) {
53
53
  if (!isLockTaken(err)) throw err;
54
54
  lastTaken = err;
55
- if (!(await dropStaleLock(lockPath, staleMs))) await waitForLock(delayMs);
55
+ if (!(await dropStaleLock(lockPath, staleMs, operations.remove))) await waitForLock(delayMs);
56
56
  }
57
57
  }
58
58
  // The code is part of the message: a timeout on EPERM points at a delete that never completed,
@@ -63,14 +63,45 @@ async function acquireFileLock(lockPath, { retries, delayMs, staleMs, descriptio
63
63
  );
64
64
  }
65
65
 
66
- export async function withFileLock(lockPath, action, {
66
+ export async function withOutsideFileLock(writer, lockPath, action, {
67
67
  retries = 200, delayMs = 5, staleMs = 30_000, description = 'file',
68
68
  } = {}) {
69
- const lock = await acquireFileLock(lockPath, { retries, delayMs, staleMs, description });
69
+ writer.assertOutside(lockPath);
70
+ const remove = (target) => fs.rm(target, { force: true });
71
+ const lock = await acquireFileLock(lockPath, { retries, delayMs, staleMs, description }, {
72
+ mkdir(directory, options) { return fs.mkdir(directory, options); },
73
+ open(lockFile) { return fs.open(lockFile, 'wx'); },
74
+ remove,
75
+ });
70
76
  try {
71
77
  return await action();
72
78
  } finally {
73
79
  await lock.handle.close().catch(() => {});
74
- await fs.rm(lock.lockPath, { force: true }).catch(() => {});
80
+ await lock.remove(lock.lockPath).catch(() => {});
81
+ }
82
+ }
83
+
84
+ export async function withHomeFileLock(writer, id, lockPath, action, {
85
+ retries = 200, delayMs = 5, staleMs = 30_000, description = 'file',
86
+ } = {}) {
87
+ // Plan_65 B2: the lock's parent is shared by artifacts, so a wrong id is refused before mkdir.
88
+ writer.assertArtifact(id, lockPath);
89
+ const remove = async (target) => {
90
+ try {
91
+ return await writer.unlink(id, target);
92
+ } catch (error) {
93
+ if (error?.code !== 'ENOENT') throw error;
94
+ }
95
+ };
96
+ const lock = await acquireFileLock(lockPath, { retries, delayMs, staleMs, description }, {
97
+ mkdir(directory, options) { return writer.mkdir(id, directory, options); },
98
+ open(lockFile) { return writer.open(id, lockFile, 'wx'); },
99
+ remove,
100
+ });
101
+ try {
102
+ return await action();
103
+ } finally {
104
+ await lock.handle.close().catch(() => {});
105
+ await lock.remove(lock.lockPath).catch(() => {});
75
106
  }
76
107
  }
@@ -1,27 +1,33 @@
1
- /** Records the 2026-09-17..24 silent SubagentHandback contract break before it can go unnoticed again. */
1
+ /** Records the 2026-09-17..24 handback break; 2026-09-25 proved SDK env is inherited, so identify hosts from transcripts. */
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
- import { writeJsonAtomic } from './atomic-json.mjs';
4
+ import { writeHomeJsonAtomic } from './atomic-json.mjs';
5
5
  import { parseJsonText } from './json-file.mjs';
6
- import { withFileLock } from './file-lock.mjs';
6
+ import { withHomeFileLock } from './file-lock.mjs';
7
+ import { stateDirWriter } from './home-write.mjs';
8
+ import { transcriptHostVersion } from './host-version.mjs';
7
9
 
8
10
  export const WITNESS_FILE = 'handback-witness.json';
9
11
 
10
- export function hostSdkVersion(env = process.env) {
11
- const version = env.CLAUDE_AGENT_SDK_VERSION;
12
- if (typeof version !== 'string') return null;
13
- return version.trim() || null;
12
+ export async function witnessHostVersion(payload) {
13
+ return transcriptHostVersion(payload?.transcript_path);
14
14
  }
15
15
 
16
- function isWitness(record) {
17
- const witnessEntry = (entry, alarm = false) => entry && typeof entry === 'object'
18
- && !Array.isArray(entry)
19
- && (entry.sdkVersion === null || typeof entry.sdkVersion === 'string')
20
- && typeof entry.at === 'string'
21
- && (!alarm || typeof entry.detail === 'string');
22
- return record && typeof record === 'object' && !Array.isArray(record)
23
- && (record.lastSeen === null || witnessEntry(record.lastSeen))
24
- && Array.isArray(record.alarms) && record.alarms.every((entry) => witnessEntry(entry, true));
16
+ function normalizeWitness(record) {
17
+ const alarmEntry = (entry) => entry && typeof entry === 'object' && !Array.isArray(entry)
18
+ && (entry.hostVersion === null || typeof entry.hostVersion === 'string')
19
+ && typeof entry.at === 'string' && typeof entry.detail === 'string';
20
+ if (!record || typeof record !== 'object' || Array.isArray(record) || !Array.isArray(record.alarms)) return null;
21
+ if (record.alarms.every(alarmEntry) && record.lastSeen && typeof record.lastSeen === 'object'
22
+ && !Array.isArray(record.lastSeen) && !Object.hasOwn(record.lastSeen, 'sdkVersion')
23
+ && !Object.hasOwn(record.lastSeen, 'at') && Object.values(record.lastSeen).every((at) => typeof at === 'string')) return record;
24
+ const legacy = record.lastSeen === null || (record.lastSeen && typeof record.lastSeen === 'object'
25
+ && !Array.isArray(record.lastSeen) && typeof record.lastSeen.sdkVersion === 'string' && typeof record.lastSeen.at === 'string');
26
+ const legacyAlarm = (entry) => entry && typeof entry === 'object' && !Array.isArray(entry)
27
+ && (entry.sdkVersion === null || typeof entry.sdkVersion === 'string') && typeof entry.at === 'string'
28
+ && typeof entry.detail === 'string';
29
+ if (legacy && record.alarms.every(legacyAlarm)) return { lastSeen: {}, alarms: record.alarms.map(({ at, detail }) => ({ hostVersion: null, at, detail })) };
30
+ return null;
25
31
  }
26
32
 
27
33
  export function readHandbackWitness({ stateDir }) {
@@ -30,12 +36,12 @@ export function readHandbackWitness({ stateDir }) {
30
36
  try {
31
37
  source = fs.readFileSync(file, 'utf8');
32
38
  } catch (error) {
33
- if (error.code === 'ENOENT') return { lastSeen: null, alarms: [] };
39
+ if (error.code === 'ENOENT') return { lastSeen: {}, alarms: [] };
34
40
  return { corrupt: true };
35
41
  }
36
42
  try {
37
43
  const record = parseJsonText(file, source);
38
- return isWitness(record) ? record : { corrupt: true };
44
+ return normalizeWitness(record) || { corrupt: true };
39
45
  } catch {
40
46
  return { corrupt: true };
41
47
  }
@@ -46,20 +52,22 @@ function timestamp(now) {
46
52
  }
47
53
 
48
54
 
49
- export async function recordHandbackWitness({ stateDir, kind, sdkVersion, detail = '', now = new Date() }) {
55
+ export async function recordHandbackWitness({ stateDir, kind, hostVersion, detail = '', now = new Date() }) {
50
56
  if (kind !== 'seen' && kind !== 'alarm') throw new TypeError("kind must be 'seen' or 'alarm'");
51
- if (sdkVersion !== null && typeof sdkVersion !== 'string') {
52
- throw new TypeError('sdkVersion must be a string or null');
57
+ if (hostVersion !== null && typeof hostVersion !== 'string') {
58
+ throw new TypeError('hostVersion must be a string or null');
53
59
  }
54
60
  const file = path.join(stateDir, WITNESS_FILE);
55
- return withFileLock(`${file}.lock`, async () => {
61
+ // Plan_65 B6: the witness, its lock and temporaries are the registered handback-witness artifact.
62
+ const writer = stateDirWriter(stateDir);
63
+ return withHomeFileLock(writer, 'handback-witness', `${file}.lock`, async () => {
56
64
  const current = readHandbackWitness({ stateDir });
57
65
  if (current.corrupt) throw new Error(`Cannot update corrupt handback witness: ${file}`);
58
66
  const at = timestamp(now);
59
67
  const record = kind === 'seen'
60
- ? { ...current, lastSeen: { sdkVersion, at } }
61
- : { ...current, alarms: [...current.alarms, { sdkVersion, at, detail }].slice(-20) };
62
- writeJsonAtomic(file, record);
68
+ ? { ...current, lastSeen: hostVersion === null ? current.lastSeen : { ...current.lastSeen, [hostVersion]: at } }
69
+ : { ...current, alarms: [...current.alarms, { hostVersion, at, detail }].slice(-20) };
70
+ writeHomeJsonAtomic(writer, 'handback-witness', file, record);
63
71
  return record;
64
72
  }, { description: 'handback witness' });
65
73
  }