@bitkyc08/opencodex 2.82.0 → 2.84.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 (266) hide show
  1. package/gui/dist/assets/App-CZtqiGcw.js +56 -0
  2. package/gui/dist/assets/Tray-C9TGtwad.js +1 -0
  3. package/gui/dist/assets/{index-BAbcj51J.css → index-BB6uK6tU.css} +1 -1
  4. package/gui/dist/assets/index-CTm4l-HQ.js +94 -0
  5. package/gui/dist/assets/{quota-summary-DTBC8n7-.js → quota-summary-BbEeRfPY.js} +1 -1
  6. package/gui/dist/index.html +2 -2
  7. package/package.json +2 -2
  8. package/src/adapters/coding-agent/protocol.ts +15 -8
  9. package/src/adapters/cursor/catalog.ts +2 -2
  10. package/src/adapters/cursor/effort-map.ts +1 -1
  11. package/src/adapters/devin/live-models.ts +2 -2
  12. package/src/adapters/google-truncation.ts +22 -0
  13. package/src/adapters/google.ts +7 -3
  14. package/src/adapters/ollama-native.ts +47 -15
  15. package/src/adapters/openai-chat/messages.ts +8 -4
  16. package/src/adapters/openai-responses/empty-assistant.ts +32 -0
  17. package/src/adapters/openai-responses/passthrough.ts +5 -0
  18. package/src/adapters/openai-responses/plaintext-reasoning-notice.ts +85 -0
  19. package/src/adapters/openai-responses/reasoning.ts +11 -1
  20. package/src/adapters/openai-responses/request-strips.ts +10 -2
  21. package/src/adapters/qoder/adapter.ts +16 -14
  22. package/src/bridge/errors.ts +26 -1
  23. package/src/bridge.ts +1 -1
  24. package/src/claude/context-windows.ts +57 -4
  25. package/src/claude/desktop-picker.ts +1 -1
  26. package/src/claude/intercept/connect-proxy.ts +10 -3
  27. package/src/claude/intercept/picker-ca-persistence.ts +3 -1
  28. package/src/claude/intercept/picker-listener.ts +49 -1
  29. package/src/claude/intercept/settings-no-proxy.ts +146 -0
  30. package/src/claude/intercept/settings.ts +101 -9
  31. package/src/cli/account-api.ts +65 -0
  32. package/src/cli/account-auth.ts +44 -12
  33. package/src/cli/account.ts +41 -5
  34. package/src/cli/capabilities-access-remote.ts +8 -0
  35. package/src/cli/capabilities-base.ts +13 -1
  36. package/src/cli/catalog-command-result.ts +2 -1
  37. package/src/cli/claude.ts +15 -6
  38. package/src/cli/config-command.ts +8 -2
  39. package/src/cli/debug.ts +8 -0
  40. package/src/cli/dispatch.ts +16 -3
  41. package/src/cli/export-command.ts +14 -1
  42. package/src/cli/hub-disable.ts +99 -0
  43. package/src/cli/hub.ts +4 -2
  44. package/src/cli/index.ts +13 -4
  45. package/src/cli/link-child.ts +104 -0
  46. package/src/cli/link-enrollment.ts +96 -0
  47. package/src/cli/link.ts +39 -24
  48. package/src/cli/observe-stream.ts +6 -1
  49. package/src/cli/observe.ts +10 -2
  50. package/src/cli/provider.ts +12 -0
  51. package/src/cli/registry.ts +5 -4
  52. package/src/cli/resolve.ts +1 -1
  53. package/src/cli/runtime-api.ts +27 -2
  54. package/src/cli/status.ts +104 -20
  55. package/src/client/connect.ts +39 -4
  56. package/src/client/hub-client.ts +14 -7
  57. package/src/client/hub-state.ts +25 -14
  58. package/src/client/link-home.ts +248 -0
  59. package/src/client/link-management-relay.ts +61 -0
  60. package/src/client/link-relay.ts +6 -1
  61. package/src/client/link-status.ts +14 -0
  62. package/src/client/machine-api.ts +14 -7
  63. package/src/client/machine-listener.ts +29 -4
  64. package/src/clients/config-export/model-metadata.ts +16 -0
  65. package/src/clients/config-export/zcode-store.ts +46 -11
  66. package/src/clients/config-export/zcode.ts +3 -5
  67. package/src/clients/config-export.ts +47 -17
  68. package/src/clients/omo-role-models.ts +6 -3
  69. package/src/codex/agent-role-models.ts +26 -8
  70. package/src/codex/auth-api/account-list.ts +17 -2
  71. package/src/codex/auth-api/account-selection.ts +17 -7
  72. package/src/codex/auth-api/pool-mode-gate.ts +4 -5
  73. package/src/codex/auth-api/routes.ts +5 -1
  74. package/src/codex/auth-context.ts +8 -3
  75. package/src/codex/autostart-health.ts +3 -1
  76. package/src/codex/catalog/bundled.ts +5 -1
  77. package/src/codex/catalog/effort.ts +2 -2
  78. package/src/codex/catalog/metadata.ts +4 -2
  79. package/src/codex/catalog/native-models.ts +17 -3
  80. package/src/codex/catalog/provider-models.ts +25 -0
  81. package/src/codex/desktop-app/darwin.ts +1 -1
  82. package/src/codex/desktop-app/handoff.ts +1 -1
  83. package/src/codex/desktop-app/linux.ts +1 -1
  84. package/src/codex/desktop-app/lock.ts +1 -1
  85. package/src/codex/desktop-app/types.ts +1 -1
  86. package/src/codex/desktop-app/windows.ts +1 -1
  87. package/src/codex/desktop-app-restart.ts +1 -1
  88. package/src/codex/main-account.ts +6 -7
  89. package/src/codex/native-profile-startup.ts +4 -1
  90. package/src/codex/prompt-journal.ts +20 -2
  91. package/src/codex/prompt-layers/encoding.ts +49 -0
  92. package/src/codex/prompt-layers/import-source.ts +30 -0
  93. package/src/codex/prompt-layers/toml-edit.ts +269 -37
  94. package/src/codex/prompt-layers/toml-read.ts +52 -4
  95. package/src/codex/prompt-layers.ts +383 -20
  96. package/src/codex/prompt-lock-claim.ts +13 -3
  97. package/src/codex/prompt-lock-evidence.ts +3 -3
  98. package/src/codex/prompt-lock-owner.ts +14 -6
  99. package/src/codex/prompt-text-probe.ts +34 -5
  100. package/src/codex/quota.ts +13 -6
  101. package/src/codex/role-auto-assign.ts +2 -5
  102. package/src/codex/routing/active-account.ts +21 -0
  103. package/src/codex/routing.ts +1 -1
  104. package/src/codex/runtime.ts +196 -8
  105. package/src/codex/shim-overlay.ts +8 -4
  106. package/src/codex/shim.ts +1 -1
  107. package/src/combos/failover.ts +3 -0
  108. package/src/config/api-key-reconcile.ts +34 -0
  109. package/src/config/diagnostics.ts +16 -0
  110. package/src/config/feature-flags.ts +5 -0
  111. package/src/config/live-reconcile.ts +43 -29
  112. package/src/config/live-topology.ts +29 -0
  113. package/src/config/load-degrade.ts +2 -1
  114. package/src/config/paths.ts +3 -0
  115. package/src/config/salvage.ts +3 -1
  116. package/src/config/schema/config-schema.ts +2 -0
  117. package/src/generated/compatibility-version.json +373 -217
  118. package/src/grok/grpc-web.ts +23 -1
  119. package/src/grok/inject.ts +245 -221
  120. package/src/grok/reset-coupon-ledger.ts +293 -57
  121. package/src/grok/reset-coupons.ts +15 -6
  122. package/src/integrations/cursor-detect.ts +1 -1
  123. package/src/integrations/mutation-plan.ts +13 -7
  124. package/src/integrations/registry.ts +45 -11
  125. package/src/integrations/state.ts +19 -7
  126. package/src/integrations/target.ts +42 -5
  127. package/src/integrations/writer.ts +14 -6
  128. package/src/lib/app-owned-memory-stores.ts +4 -0
  129. package/src/lib/app-owned-memory.ts +11 -0
  130. package/src/lib/errors.ts +25 -0
  131. package/src/lib/json-byte-size.ts +31 -0
  132. package/src/lib/optional-shutdown-hooks.ts +35 -0
  133. package/src/lib/outbound-credential-registry.ts +210 -0
  134. package/src/lib/passive-read.ts +35 -0
  135. package/src/lib/redact.ts +2 -2
  136. package/src/lib/shadow-call.ts +44 -5
  137. package/src/link/codex-settings.ts +155 -0
  138. package/src/link/management-routes.ts +89 -0
  139. package/src/link/remote-probe.ts +38 -0
  140. package/src/link/ssh-argv.ts +26 -5
  141. package/src/link/ssh-runner.ts +58 -27
  142. package/src/link/status-projection.ts +22 -4
  143. package/src/link/supervisor.ts +144 -49
  144. package/src/link/tunnel-state.ts +11 -4
  145. package/src/oauth/anthropic-continuity.ts +4 -3
  146. package/src/oauth/anthropic-identity.ts +13 -2
  147. package/src/oauth/anthropic-model-quota.ts +13 -1
  148. package/src/oauth/anthropic-routing.ts +230 -23
  149. package/src/oauth/anthropic.ts +18 -4
  150. package/src/oauth/google-antigravity.ts +24 -7
  151. package/src/oauth/index.ts +25 -16
  152. package/src/oauth/kiro-account-identity.ts +106 -0
  153. package/src/oauth/kiro.ts +48 -5
  154. package/src/oauth/meta-muse-device.ts +1 -1
  155. package/src/oauth/plan.ts +29 -0
  156. package/src/oauth/store.ts +40 -3
  157. package/src/oauth/types.ts +3 -1
  158. package/src/plugins/loader.ts +11 -0
  159. package/src/providers/anthropic-billing-ownership.ts +74 -0
  160. package/src/providers/anthropic-billing-recovery.ts +31 -0
  161. package/src/providers/anthropic-billing-refusal.ts +55 -0
  162. package/src/providers/anthropic-fast.ts +1 -1
  163. package/src/providers/anthropic-reset-admission.ts +62 -0
  164. package/src/providers/anthropic-reset-grant-ledger.ts +29 -7
  165. package/src/providers/api-key-resolve.ts +17 -2
  166. package/src/providers/devin-cli-authmode-migration.ts +1 -1
  167. package/src/providers/devin-provider-merge-migration.ts +1 -1
  168. package/src/providers/github-copilot-auto.ts +169 -0
  169. package/src/providers/key-failover.ts +52 -5
  170. package/src/providers/model-presets.ts +1 -1
  171. package/src/providers/model-rename-startup.ts +5 -3
  172. package/src/providers/muse-subscription-usage.ts +1 -1
  173. package/src/providers/quota/account-cache.ts +11 -0
  174. package/src/providers/quota/anthropic-account-quota.ts +4 -2
  175. package/src/providers/quota/report-cache.ts +14 -0
  176. package/src/providers/quota/vendor-probes-oauth.ts +3 -3
  177. package/src/providers/quota.ts +4 -1
  178. package/src/providers/registry/entries-core.ts +20 -20
  179. package/src/providers/registry/entries-extended.ts +4 -1
  180. package/src/providers/registry/model-seeds.ts +20 -3
  181. package/src/providers/request-pacing.ts +9 -0
  182. package/src/providers/stale-no-vision-seed-migration.ts +72 -0
  183. package/src/providers/xai-fast-model.ts +1 -1
  184. package/src/responses/compaction.ts +14 -0
  185. package/src/responses/parser.ts +7 -1
  186. package/src/server/auth-cors.ts +42 -0
  187. package/src/server/configured-credentials.ts +194 -0
  188. package/src/server/inbound-body-admission.ts +24 -7
  189. package/src/server/index/link-listener.ts +30 -17
  190. package/src/server/index/link-management.ts +58 -0
  191. package/src/server/index/optional-listeners.ts +5 -1
  192. package/src/server/index/serve-options.ts +21 -2
  193. package/src/server/index.ts +1 -1
  194. package/src/server/lifecycle.ts +33 -5
  195. package/src/server/management/agent-settings-routes.ts +28 -10
  196. package/src/server/management/anthropic-reset-grant-routes.ts +17 -1
  197. package/src/server/management/codex-agent-role-routes.ts +19 -6
  198. package/src/server/management/codex-prompt-routes.ts +136 -5
  199. package/src/server/management/codex-role-auto-assign.ts +1 -1
  200. package/src/server/management/grok-coupon-routes.ts +333 -57
  201. package/src/server/management/link-enrollment.ts +192 -0
  202. package/src/server/management/link-route-state.ts +185 -0
  203. package/src/server/management/link-routes.ts +92 -390
  204. package/src/server/management/model-rows.ts +3 -1
  205. package/src/server/management/provider-overwrite-carry.ts +10 -0
  206. package/src/server/management/provider-routes.ts +1 -0
  207. package/src/server/management/route-registry.ts +7 -5
  208. package/src/server/management/shared.ts +3 -0
  209. package/src/server/management-api.ts +3 -1
  210. package/src/server/messages-native.ts +68 -42
  211. package/src/server/relay-eager.ts +4 -0
  212. package/src/server/relay.ts +18 -7
  213. package/src/server/request-log-failure-attribution.ts +3 -0
  214. package/src/server/request-log-filter.ts +6 -1
  215. package/src/server/request-log-terminal-status.ts +243 -0
  216. package/src/server/request-log.ts +15 -118
  217. package/src/server/responses/adapter-continuation.ts +9 -1
  218. package/src/server/responses/adapter-dispatch.ts +293 -81
  219. package/src/server/responses/code-mode-collab-surface.ts +205 -0
  220. package/src/server/responses/codex-ws-exchange.ts +147 -6
  221. package/src/server/responses/codex-ws-wire.ts +203 -0
  222. package/src/server/responses/collaboration.ts +36 -7
  223. package/src/server/responses/compact.ts +129 -50
  224. package/src/server/responses/context-overflow.ts +13 -1
  225. package/src/server/responses/core-codex-account.ts +105 -1
  226. package/src/server/responses/core-combo-failure.ts +14 -0
  227. package/src/server/responses/core-combo.ts +7 -2
  228. package/src/server/responses/core-errors.ts +29 -1
  229. package/src/server/responses/core-opaque-recovery.ts +5 -5
  230. package/src/server/responses/core-options.ts +2 -0
  231. package/src/server/responses/core.ts +6 -6
  232. package/src/server/responses/deferred-collab-surface.ts +158 -0
  233. package/src/server/responses/deferred-send-reporter.ts +25 -0
  234. package/src/server/responses/empty-completion-guard.ts +18 -2
  235. package/src/server/responses/fetch-helpers.ts +12 -1
  236. package/src/server/responses/jev-model-invoke.ts +1 -1
  237. package/src/server/responses/passthrough-delivery.ts +4 -1
  238. package/src/server/responses/passthrough-dispatch.ts +130 -64
  239. package/src/server/responses/passthrough-execution.ts +3 -3
  240. package/src/server/responses/request-prepare.ts +6 -4
  241. package/src/server/responses/request-transport.ts +131 -13
  242. package/src/server/responses/run-turn-execution.ts +13 -5
  243. package/src/server/responses/sidecar-execution.ts +16 -2
  244. package/src/server/responses/spend-storage-error.ts +33 -0
  245. package/src/server/responses/ws-upstream.ts +9 -1
  246. package/src/server/startup-action-control.ts +22 -2
  247. package/src/server/startup-health-cache.ts +4 -0
  248. package/src/server/system-env-shell.ts +7 -2
  249. package/src/server/system-env.ts +11 -1
  250. package/src/service/cli.ts +5 -1
  251. package/src/service/desktop-startup.ts +147 -34
  252. package/src/service/desktop-supervision.d.mts +27 -1
  253. package/src/service/desktop-supervision.mjs +111 -5
  254. package/src/service/health-sentinel-launch.ts +176 -0
  255. package/src/service/health-sentinel-main.ts +447 -0
  256. package/src/service/health-sentinel-policy.ts +241 -0
  257. package/src/service/health-sentinel-probe.ts +54 -0
  258. package/src/service/health-sentinel-windows.ts +87 -0
  259. package/src/service/linux-linger.ts +20 -0
  260. package/src/types/config.ts +5 -0
  261. package/src/types/request.ts +5 -0
  262. package/src/usage/expected-prices.ts +5 -1
  263. package/src/usage/log.ts +19 -1
  264. package/gui/dist/assets/App-Ds_qL7o8.js +0 -54
  265. package/gui/dist/assets/Tray-BqHF5Trd.js +0 -1
  266. package/gui/dist/assets/index-D3FBCNiP.js +0 -94
@@ -1,5 +1,5 @@
1
1
  import { OCX_SECTION_MARKER } from "../injected-marker";
2
- import { decodeBasicString } from "./encoding";
2
+ import { decodeBasicString, lexicalLineStarts, matchKeyHead } from "./encoding";
3
3
 
4
4
  /**
5
5
  * Decoded string entries of a root-scope TOML array.
@@ -118,6 +118,25 @@ export function rootLines(content: string): string[] {
118
118
  return first === -1 ? lines : lines.slice(0, first);
119
119
  }
120
120
 
121
+ /**
122
+ * Root-scope lines that start outside every string and composite span. Used by
123
+ * fallbacks that scan lines because the parser refused the file (for example a
124
+ * Codex-valid i64 Bun cannot represent): prose inside a multiline value must not
125
+ * be read as a setting. A file whose spans never close keeps the plain scan.
126
+ */
127
+ export function scopedRootLines(content: string): string[] {
128
+ const lines = content.split("\n");
129
+ const starts = lexicalLineStarts(lines);
130
+ if (starts === null) return rootLines(content);
131
+ const out: string[] = [];
132
+ for (let i = 0; i < lines.length; i += 1) {
133
+ if (!starts[i]) continue;
134
+ if (TABLE_HEADER.test(lines[i]!)) break;
135
+ out.push(lines[i]!);
136
+ }
137
+ return out;
138
+ }
139
+
121
140
  /** Lines of `[header]`'s body, up to the next table header. */
122
141
  export function tableLines(content: string, header: string): string[] | null {
123
142
  const lines = content.split("\n");
@@ -129,11 +148,40 @@ export function tableLines(content: string, header: string): string[] | null {
129
148
  return end === -1 ? rest : rest.slice(0, end);
130
149
  }
131
150
 
151
+ /**
152
+ * A root-scope (`table === null`) or `[table]` boolean, reading only lines that
153
+ * start outside every string and composite span, so assignment-shaped prose in a
154
+ * multiline value is never reported as the setting. A file whose spans never
155
+ * close falls back to the plain line scan; Codex cannot load it either way.
156
+ */
157
+ export function scopedBool(content: string, table: string | null, key: string): boolean | null {
158
+ const lines = content.split("\n");
159
+ const starts = lexicalLineStarts(lines);
160
+ if (starts === null) {
161
+ const scope = table === null ? rootLines(content) : tableLines(content, table);
162
+ return scope === null ? null : boolInLines(scope, key);
163
+ }
164
+ const top = (i: number) => starts[i] === true;
165
+ let from = 0;
166
+ if (table !== null) {
167
+ const escaped = table.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
168
+ const header = new RegExp(`^\\s*\\[${escaped}\\]\\s*(?:#.*)?$`);
169
+ from = lines.findIndex((l, i) => top(i) && header.test(l)) + 1;
170
+ if (from === 0) return null;
171
+ }
172
+ for (let i = from; i < lines.length; i += 1) {
173
+ if (!top(i)) continue;
174
+ if (TABLE_HEADER.test(lines[i]!)) break;
175
+ const value = boolInLines([lines[i]!], key);
176
+ if (value !== null) return value;
177
+ }
178
+ return null;
179
+ }
180
+
132
181
  export function boolInLines(lines: string[], key: string): boolean | null {
133
- const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
134
- const pattern = new RegExp(`^\\s*${escaped}\\s*=\\s*(true|false)\\s*(?:#.*)?$`);
135
182
  for (const line of lines) {
136
- const m = pattern.exec(line);
183
+ const head = matchKeyHead(line, key);
184
+ const m = head === null ? null : /^(true|false)\s*(?:#.*)?$/.exec(head.rest);
137
185
  if (m) return m[1] === "true";
138
186
  }
139
187
  return null;
@@ -26,7 +26,7 @@
26
26
  * CODEX_HOME is resolved at CALL time (the `features.ts:58-67` pattern) so tests
27
27
  * can point fixtures via env or an explicit path.
28
28
  */
29
- import { existsSync, readdirSync } from "node:fs";
29
+ import { existsSync, readdirSync, realpathSync } from "node:fs";
30
30
  import { dirname, join, resolve } from "node:path";
31
31
  import { createHash, randomBytes } from "node:crypto";
32
32
  import { expandUserPath } from "../config";
@@ -41,7 +41,9 @@ import {
41
41
  hashBytes,
42
42
  recoverIfNeeded as recoverJournal,
43
43
  type JournalRecord,
44
+ expectingImage,
44
45
  } from "./prompt-journal";
46
+ import { readBaseImportSource } from "./prompt-layers/import-source";
45
47
  import { release, stillHeld, tryAcquire } from "./prompt-lock";
46
48
  import { configWriteLockFailureMessage, withConfigWriteLockHeld, publishConfigWrite, publishConfigWriteTarget, watchConfigWriteTargets, type LockHandle } from "./config-write-lock";
47
49
 
@@ -152,8 +154,11 @@ export type { Ownership } from "./prompt-layers/toml-read";
152
154
  import { activeConfigPath, activeStorePath, activeBaseVariantDir, journalPathFor, lockPathFor, type Paths } from "./prompt-layers/paths";
153
155
  import { readFileOrNull, computeRevision, updateFingerprintField } from "./prompt-layers/revision";
154
156
  import { normalizeBody, findInvalidCharacter, decodeBasicString, decodeTomlBasicString } from "./prompt-layers/encoding";
155
- import { rootArrayEntries, hasRootKey, rootLines, rootValue, tableLines, boolInLines, inspectOwnership } from "./prompt-layers/toml-read";
156
- import { setRootBool, setRootString, setTableBool, setProjection, removeUnownedProjection } from "./prompt-layers/toml-edit";
157
+ import { rootArrayEntries, hasRootKey, rootLines, rootValue, scopedBool, scopedRootLines, inspectOwnership } from "./prompt-layers/toml-read";
158
+ import {
159
+ setRootBool, setRootString, setTableBool, setProjection, removeUnownedProjection,
160
+ rootKeyValueForm, UnsupportedTomlForm,
161
+ } from "./prompt-layers/toml-edit";
157
162
 
158
163
  /**
159
164
  * Instruction documents the prompt probe renders out of CODEX_HOME, in the
@@ -355,8 +360,7 @@ function readToggle(configBytes: string | null, id: ToggleId): ToggleState {
355
360
  const key = spec.table ? `${spec.table}.${spec.key}` : spec.key;
356
361
  let value: boolean | null = null;
357
362
  if (configBytes !== null) {
358
- const scope = spec.table ? tableLines(configBytes, spec.table) : rootLines(configBytes);
359
- value = scope === null ? null : boolInLines(scope, spec.key);
363
+ value = scopedBool(configBytes, spec.table ?? null, spec.key);
360
364
  }
361
365
  return {
362
366
  id,
@@ -372,7 +376,7 @@ function readModelInstructionsFile(configBytes: string | null): string | null {
372
376
  const parsed = rootValue(configBytes, "model_instructions_file");
373
377
  if (typeof parsed === "string") return parsed;
374
378
  if (parsed === undefined) return null;
375
- for (const line of rootLines(configBytes)) {
379
+ for (const line of scopedRootLines(configBytes)) {
376
380
  // Capture the whole literal INCLUDING its quotes and decode it, rather than
377
381
  // returning the raw inner text. `setRootString` writes this key through
378
382
  // `encodeBasicString`, which escapes backslashes, so on Windows the stored
@@ -380,7 +384,7 @@ function readModelInstructionsFile(configBytes: string | null): string | null {
380
384
  //
381
385
  // Bun may reject an unrelated safe-for-Codex integer. Decode the standard
382
386
  // TOML escapes here, and never mistake an undecodable literal for a path.
383
- const m = /^\s*model_instructions_file\s*=\s*("(?:[^"\\]|\\.)*")\s*(?:#.*)?$/.exec(line);
387
+ const m = /^\s*model_instructions_file\s*=\s*("(?:[^"\\]|\\.)*")\s*(?:#.*)?$/.exec(line.replace(/\r$/, ""));
384
388
  if (m) return decodeTomlBasicString(m[1]!) ?? "<unreadable model_instructions_file>";
385
389
  }
386
390
  // A present non-string value or unrecognised spelling fails closed. Only
@@ -448,7 +452,7 @@ export function resolveBaseSelection(
448
452
  const dir = activeBaseVariantDir(opts);
449
453
  let resolved: string;
450
454
  try {
451
- resolved = resolve(expandUserPath(raw));
455
+ resolved = resolve(dirname(activeConfigPath(opts)), expandUserPath(raw));
452
456
  } catch {
453
457
  return { kind: "external", path: raw };
454
458
  }
@@ -640,6 +644,7 @@ export type WriteError =
640
644
  | "unknown_layer"
641
645
  | "store_unreadable"
642
646
  | "invalid_characters"
647
+ | "body_too_large"
643
648
  | "write_superseded"
644
649
  // The filesystem refused a rename that passed every precondition: a directory on
645
650
  // the store path, a mode change, a full disk. Distinct from write_superseded,
@@ -647,7 +652,14 @@ export type WriteError =
647
652
  | "write_failed"
648
653
  | "recovery_required"
649
654
  | "locked"
650
- | "unsafe";
655
+ | "unsafe"
656
+ | "import_body_changed"
657
+ /**
658
+ * A `key =` line exists but its value is a form the line editor cannot
659
+ * replace or remove safely (a multi-line string span, an unterminated
660
+ * quote). Refused before the transaction wrote anything.
661
+ */
662
+ | "unsupported_form";
651
663
 
652
664
  export type WriteResult =
653
665
  | { ok: true; changed: boolean; snapshot: PromptLayerSnapshot }
@@ -712,7 +724,18 @@ function commitPrepared(
712
724
  }
713
725
 
714
726
  const snapshot = readPromptLayers({ ...opts, configPath, storePath });
715
- const built = build(snapshot, configBytes, storeBytes);
727
+ let built: Mutation | { error: WriteError; detail?: string };
728
+ try {
729
+ built = build(snapshot, configBytes, storeBytes);
730
+ } catch (error) {
731
+ // A line editor that cannot see the end of the value span refuses BEFORE
732
+ // the journal exists — nothing has been written, so a plain error result
733
+ // is the whole answer.
734
+ if (error instanceof UnsupportedTomlForm) {
735
+ return { ok: false, error: "unsupported_form", detail: error.message };
736
+ }
737
+ throw error;
738
+ }
716
739
  if ("error" in built) return { ok: false, error: built.error, detail: built.detail };
717
740
 
718
741
  const { nextConfig, nextStore } = built;
@@ -756,8 +779,8 @@ function commitPrepared(
756
779
  return rollback(record, journalPath, "stale_revision", configHandle);
757
780
  }
758
781
  publishConfigWrite(configPath, configHandle, (destination, hooks) => {
759
- if (nextConfig === null) durableDelete(destination, hooks);
760
- else durableWrite(destination, nextConfig, hooks);
782
+ if (nextConfig === null) durableDelete(destination, expectingImage(hooks, record.preConfig));
783
+ else durableWrite(destination, nextConfig, expectingImage(hooks, record.preConfig));
761
784
  });
762
785
  }
763
786
  if (storeChanged) {
@@ -765,8 +788,8 @@ function commitPrepared(
765
788
  return rollback(record, journalPath, "stale_revision", configHandle);
766
789
  }
767
790
  publishConfigWriteTarget(configPath, configHandle, storePath, (destination, hooks) => {
768
- if (nextStore === null) durableDelete(destination, hooks);
769
- else durableWrite(destination, nextStore, hooks);
791
+ if (nextStore === null) durableDelete(destination, expectingImage(hooks, record.preStore));
792
+ else durableWrite(destination, nextStore, expectingImage(hooks, record.preStore));
770
793
  });
771
794
  }
772
795
  } catch (error) {
@@ -810,14 +833,14 @@ function rollback(record: JournalRecord, journalPath: string, error: WriteError,
810
833
  }
811
834
  if (configNow === record.postConfig) {
812
835
  publishConfigWrite(record.configPath, held, (destination, hooks) => {
813
- if (record.preConfigBytes === null) durableDelete(destination, hooks);
814
- else durableWrite(destination, record.preConfigBytes, hooks);
836
+ if (record.preConfigBytes === null) durableDelete(destination, expectingImage(hooks, record.postConfig));
837
+ else durableWrite(destination, record.preConfigBytes, expectingImage(hooks, record.postConfig));
815
838
  });
816
839
  }
817
840
  if (storeNow === record.postStore) {
818
841
  publishConfigWriteTarget(record.configPath, held, record.storePath, (destination, hooks) => {
819
- if (record.preStoreBytes === null) durableDelete(destination, hooks);
820
- else durableWrite(destination, record.preStoreBytes, hooks);
842
+ if (record.preStoreBytes === null) durableDelete(destination, expectingImage(hooks, record.postStore));
843
+ else durableWrite(destination, record.preStoreBytes, expectingImage(hooks, record.postStore));
821
844
  });
822
845
  }
823
846
  publishConfigWriteTarget(record.configPath, held, journalPath, (destination, hooks) => durableDelete(destination, hooks));
@@ -825,8 +848,16 @@ function rollback(record: JournalRecord, journalPath: string, error: WriteError,
825
848
  } catch { return { ok: false, error: "recovery_required" }; }
826
849
  }
827
850
 
828
- /** Flip one of the five prompt toggles. */
829
- export function setToggle(id: string, enabled: boolean, revision: string, opts?: Paths): WriteResult {
851
+ /**
852
+ * Flip one of the five prompt toggles, or REMOVE its key with `enabled: null`.
853
+ *
854
+ * Removal is the "restore default" verb. A toggle's documented default lives in
855
+ * Codex, not in this file: writing `key = true` for a layer whose default is
856
+ * true produces the same prompt today but freezes the override - an upstream
857
+ * change to the default would then never reach this user. Deleting the line is
858
+ * the only state that keeps following it.
859
+ */
860
+ export function setToggle(id: string, enabled: boolean | null, revision: string, opts?: Paths): WriteResult {
830
861
  if (!isToggleId(id)) return { ok: false, error: "unknown_layer" };
831
862
  const spec = TOGGLE_KEYS[id];
832
863
  return commit(opts, revision, (_snapshot, configBytes, storeBytes) => ({
@@ -976,6 +1007,295 @@ function newBaseVariantId(existing: readonly BaseVariant[]): string {
976
1007
  }
977
1008
  }
978
1009
 
1010
+ // ---------------------------------------------------------------------------
1011
+ // Import — taking ownership of an externally authored model_instructions_file.
1012
+ //
1013
+ // The picker refuses the `external` state outright, which made it a dead end:
1014
+ // the only way to manage that prompt was to delete the line by hand and retype
1015
+ // the file. Import copies the external file's body into the variant directory
1016
+ // and points the key at our copy — one file, so nothing upstream can edit out
1017
+ // from under a stored variant.
1018
+ // ---------------------------------------------------------------------------
1019
+
1020
+ /**
1021
+ * The single-line cap on a stored variant title — the same 80 characters the
1022
+ * route policy applies to custom-layer titles. It bounds the filename-derived
1023
+ * suggestion as well as any caller-supplied title, so the `# ` heading line
1024
+ * the serialized variant starts with stays a bounded prefix and never swallows
1025
+ * the body's byte budget silently.
1026
+ */
1027
+ export const MAX_BASE_TITLE_CHARS = 80;
1028
+
1029
+ const IMPORT_FALLBACK_TITLE = "Imported base prompt";
1030
+
1031
+ /**
1032
+ * Flatten a title to one line and bound it. Newlines become spaces rather than
1033
+ * throwing because `readBaseVariants` reads the first line back as the title —
1034
+ * a `\n` in the file would mint a second prompt line nobody asked for.
1035
+ * The bound is by UTF-16 units (the same measure the route limit uses); a cut
1036
+ * that lands mid-surrogate drops the dangling half so no unpaired surrogate is
1037
+ * ever written.
1038
+ */
1039
+ function boundTitle(raw: string | null | undefined): string | null {
1040
+ if (typeof raw !== "string") return null;
1041
+ const flat = raw.replace(/[\r\n]+/g, " ").trim();
1042
+ if (!flat) return null;
1043
+ let bounded = flat.length > MAX_BASE_TITLE_CHARS ? flat.slice(0, MAX_BASE_TITLE_CHARS) : flat;
1044
+ if (/[\uD800-\uDBFF]$/.test(bounded)) bounded = bounded.slice(0, -1);
1045
+ return bounded || null;
1046
+ }
1047
+
1048
+ export interface BaseImportPreview {
1049
+ /** The literal value of `model_instructions_file`, as configured. */
1050
+ rawPath: string | null;
1051
+ /** The absolute file the key resolves to, when it could be resolved. */
1052
+ resolvedPath: string | null;
1053
+ /** The normalized body — the file text after CRLF/tab normalization. */
1054
+ body: string | null;
1055
+ /**
1056
+ * The EXACT text a confirm would write to the variant file: the effective
1057
+ * title as a `# ` heading line followed by the normalized body. What the
1058
+ * user previews is what Codex reads — there is no second description.
1059
+ */
1060
+ serialized: string | null;
1061
+ /**
1062
+ * sha256 of `serialized`, and REQUIRED on confirm. The hash covers the title
1063
+ * line as well as the body, so a confirmation is bound to the exact text —
1064
+ * including the selected title — the caller previewed.
1065
+ */
1066
+ previewSha256: string | null;
1067
+ /**
1068
+ * The filename-derived title, already bounded to {@link MAX_BASE_TITLE_CHARS}
1069
+ * so it is displayable and storable as-is. `null` when the path names no
1070
+ * markdown file worth naming after.
1071
+ */
1072
+ suggestedTitle: string | null;
1073
+ /** The title `serialized` was built with: requested, suggested, or fallback. */
1074
+ effectiveTitle: string | null;
1075
+ reason: "ok" | "nothing_to_import" | "file_unreadable" | "invalid_characters"
1076
+ | "slots_full" | "unsupported_form" | "body_too_large";
1077
+ detail?: string;
1078
+ }
1079
+
1080
+ /**
1081
+ * Read-only. Runs the same steps a confirm would — resolve, read, normalize,
1082
+ * title, serialize — so `serialized` is byte-identical to the stored file and
1083
+ * `previewSha256` binds a confirm to exactly it.
1084
+ *
1085
+ * `title` is the caller's choice for the variant title; omitting it previews
1086
+ * the suggested (or fallback) title. Re-preview with the edited title to get a
1087
+ * hash bound to that spelling.
1088
+ *
1089
+ * `slots_full` still returns the serialized text: the preview is the place to
1090
+ * say "this would be imported if a slot were free", and refusing to show the
1091
+ * text at all would make the cap look like a read failure.
1092
+ */
1093
+ export function previewBaseImport(opts?: Paths, title?: string): BaseImportPreview {
1094
+ const configPath = activeConfigPath(opts);
1095
+ const configBytes = readFileOrNull(configPath);
1096
+ const variants = readBaseVariants(opts);
1097
+ const selection = resolveBaseSelection(configBytes, variants, opts);
1098
+ const empty: Omit<BaseImportPreview, "reason"> = {
1099
+ rawPath: selection.kind === "external" ? selection.path : null,
1100
+ resolvedPath: null,
1101
+ body: null,
1102
+ serialized: null,
1103
+ previewSha256: null,
1104
+ suggestedTitle: null,
1105
+ effectiveTitle: null,
1106
+ };
1107
+ if (selection.kind !== "external") {
1108
+ return { ...empty, reason: "nothing_to_import" };
1109
+ }
1110
+ // A key whose value is a form the line editor cannot replace (a multi-line
1111
+ // string span, an unterminated quote) can never be retargeted — say so in the
1112
+ // preview rather than let a confirm discover it inside the transaction.
1113
+ if (rootKeyValueForm(configBytes ?? "", "model_instructions_file") === "unsupported") {
1114
+ return {
1115
+ ...empty,
1116
+ reason: "unsupported_form",
1117
+ detail: "the model_instructions_file value is a TOML form this editor cannot rewrite (for example a multi-line string); edit config.toml by hand",
1118
+ };
1119
+ }
1120
+ const dir = dirname(configPath);
1121
+ let resolved: string;
1122
+ try {
1123
+ resolved = resolve(dir, expandUserPath(selection.path));
1124
+ } catch {
1125
+ return { ...empty, reason: "file_unreadable", detail: `the path could not be resolved: ${selection.path}` };
1126
+ }
1127
+ let raw: string;
1128
+ try {
1129
+ raw = readBaseImportSource(resolved);
1130
+ } catch (error) {
1131
+ const oversized = (error as NodeJS.ErrnoException).code === "EFBIG";
1132
+ return { ...empty, resolvedPath: resolved,
1133
+ reason: oversized ? "body_too_large" : "file_unreadable",
1134
+ detail: oversized ? "the file body exceeds 65536 bytes" : `could not read ${resolved}` };
1135
+ }
1136
+ const normalized = normalizeBody(raw);
1137
+ if (Buffer.byteLength(normalized, "utf8") > 64 * 1024) {
1138
+ return { ...empty, resolvedPath: resolved, reason: "body_too_large", detail: "the file body exceeds 65536 bytes" };
1139
+ }
1140
+ const invalid = findInvalidCharacter(normalized);
1141
+ if (invalid !== null) {
1142
+ return {
1143
+ ...empty,
1144
+ resolvedPath: resolved,
1145
+ reason: "invalid_characters",
1146
+ detail: `code point ${invalid.position} is a ${invalid.reason}`,
1147
+ };
1148
+ }
1149
+ const suggestedTitle = boundTitle(resolved.replace(/\\/g, "/").split("/").pop()!
1150
+ .replace(/\.(md|markdown|txt)$/i, ""));
1151
+ const effectiveTitle = boundTitle(title) ?? suggestedTitle ?? IMPORT_FALLBACK_TITLE;
1152
+ const serialized = `# ${effectiveTitle}\n${normalized}`;
1153
+ return {
1154
+ rawPath: selection.path,
1155
+ resolvedPath: resolved,
1156
+ body: normalized,
1157
+ serialized,
1158
+ previewSha256: createHash("sha256").update(serialized, "utf8").digest("hex"),
1159
+ suggestedTitle,
1160
+ effectiveTitle,
1161
+ reason: variants.length >= MAX_BASE_VARIANTS ? "slots_full" : "ok",
1162
+ };
1163
+ }
1164
+
1165
+ /**
1166
+ * True when the config still names this variant — or when nothing can be
1167
+ * proved either way. An unreadable config returns true because "the file is
1168
+ * unreferenced" must be ESTABLISHED, not assumed: deleting a variant a live
1169
+ * `model_instructions_file` points at strands the prompt in force.
1170
+ */
1171
+ function variantStillReferenced(id: string, path: string, opts?: Paths): boolean {
1172
+ const configBytes = readFileOrNull(activeConfigPath(opts));
1173
+ if (configBytes === null) return true;
1174
+ const selection = resolveBaseSelection(configBytes, readBaseVariants(opts), opts);
1175
+ if (selection.kind === "variant") return selection.id === id;
1176
+ if (selection.kind !== "external") return false;
1177
+ let selected: string;
1178
+ try {
1179
+ selected = resolve(dirname(activeConfigPath(opts)), expandUserPath(selection.path));
1180
+ } catch {
1181
+ return true;
1182
+ }
1183
+ if (selected === resolve(path)) return true;
1184
+ // A lexical match is not the only way to name the file: a symlink (or a
1185
+ // symlinked directory) resolves to it too, and resolve() does not follow
1186
+ // links. Compare canonical destinations. Only a selection that provably does
1187
+ // not exist rules the copy out; any other failure keeps it.
1188
+ try {
1189
+ return realpathSync(selected) === realpathSync(path);
1190
+ } catch (error) {
1191
+ return (error as NodeJS.ErrnoException).code !== "ENOENT";
1192
+ }
1193
+ }
1194
+
1195
+ /**
1196
+ * Copy the external file into the variant directory and retarget the key at it.
1197
+ *
1198
+ * Same ordering as `writeBaseVariant` and for the same reason: the .md is
1199
+ * written and verified before `config.toml` is ever pointed at it, and if the
1200
+ * transaction refuses, the file is deleted rather than left behind for a write
1201
+ * the caller was told did not happen — but ONLY when the refusal proves no
1202
+ * config references it. `write_superseded` and `recovery_required` can return
1203
+ * after the post-write state was applied or while its shape is unknown; in
1204
+ * that uncertainty the file AND the recovery evidence (the journal commit()
1205
+ * left behind) are preserved.
1206
+ *
1207
+ * `previewSha256` is REQUIRED — the hash binds the exact serialized text the
1208
+ * caller previewed, title line included, and a confirm without it (or one the
1209
+ * current file no longer matches) is refused without writing.
1210
+ *
1211
+ * The `external` state is RE-CHECKED inside the transaction. Between the
1212
+ * preview and the commit another writer could have adopted or cleared the key,
1213
+ * and silently repointing then would claim an import nobody asked for.
1214
+ */
1215
+ export function importBaseVariant(
1216
+ input: { title?: string; previewSha256?: string },
1217
+ revision: string,
1218
+ opts?: Paths,
1219
+ ): WriteResult {
1220
+ const preview = previewBaseImport(opts, input.title);
1221
+ if (preview.body === null || preview.serialized === null || preview.previewSha256 === null) {
1222
+ return {
1223
+ ok: false,
1224
+ error: preview.reason === "invalid_characters" ? "invalid_characters"
1225
+ : preview.reason === "body_too_large" ? "body_too_large"
1226
+ : preview.reason === "unsupported_form" ? "unsupported_form"
1227
+ : "developer_instructions_not_owned",
1228
+ detail: preview.detail,
1229
+ };
1230
+ }
1231
+ if (typeof input.previewSha256 !== "string" || input.previewSha256 !== preview.previewSha256) {
1232
+ // The caller confirmed text they previewed; a different file, or a
1233
+ // different title, today is a different import they never saw — and a
1234
+ // missing hash means nothing was previewed at all. Refuse rather than
1235
+ // install a surprise.
1236
+ return { ok: false, error: "import_body_changed", detail: "the preview no longer matches; preview it again" };
1237
+ }
1238
+ // Even with a matching hash, refuse BEFORE writing the variant file when the
1239
+ // configured key is a form the line editor cannot retarget — the preview
1240
+ // reported the same reason, so a caller that confirmed anyway hears it too.
1241
+ if (rootKeyValueForm(readFileOrNull(activeConfigPath(opts)) ?? "", "model_instructions_file") === "unsupported") {
1242
+ return { ok: false, error: "unsupported_form", detail: preview.detail };
1243
+ }
1244
+ const dir = activeBaseVariantDir(opts);
1245
+ const existing = readBaseVariants(opts);
1246
+ if (existing.length >= MAX_BASE_VARIANTS) {
1247
+ return { ok: false, error: "unknown_layer", detail: `at most ${MAX_BASE_VARIANTS} variants` };
1248
+ }
1249
+ const targetId = newBaseVariantId(existing);
1250
+ const path = join(dir, `${targetId}.md`);
1251
+ ensureDir(path);
1252
+ try {
1253
+ // The file written is the previewed serialization byte-for-byte: the same
1254
+ // string that produced previewSha256 is what lands on disk.
1255
+ durableWrite(path, preview.serialized);
1256
+ } catch (error) {
1257
+ return { ok: false, error: "write_failed", detail: error instanceof Error ? error.message : String(error) };
1258
+ }
1259
+
1260
+ const result = commit(opts, revision, (snapshot, configBytes, storeBytes) => {
1261
+ if (snapshot.baseSelection.kind !== "external") {
1262
+ return { error: "developer_instructions_not_owned", detail: "model_instructions_file no longer points at an external file" };
1263
+ }
1264
+ // The hash check above ran before either write lock was held, so the source
1265
+ // could have been rewritten while the locks were being acquired. Re-read it
1266
+ // now, under both locks, and refuse unless it still serializes to exactly
1267
+ // what the caller previewed: activating an older copy of a file that has
1268
+ // since changed is the surprise the preview hash exists to prevent.
1269
+ const recheck = previewBaseImport(opts, input.title);
1270
+ if (recheck.previewSha256 !== preview.previewSha256 || recheck.resolvedPath !== preview.resolvedPath) {
1271
+ return { error: "import_body_changed", detail: "the source changed while the import was being confirmed; preview it again" };
1272
+ }
1273
+ return {
1274
+ nextConfig: setRootString(configBytes ?? "", "model_instructions_file", resolve(path)),
1275
+ nextStore: storeBytes,
1276
+ };
1277
+ });
1278
+
1279
+ if (!result.ok) {
1280
+ // The copy is deleted only once the live config is shown not to reference
1281
+ // it, whatever the refusal. `write_superseded`/`recovery_required` can
1282
+ // return after the post-write state was applied, and even a refusal that
1283
+ // wrote nothing (the locked recheck above, a stale revision) cannot rule
1284
+ // out a non-cooperating writer having selected the visible copy in the
1285
+ // meantime. Deleting a file a live config points at strands the prompt in
1286
+ // force, which is the data loss the refusal was protecting against.
1287
+ if (!variantStillReferenced(targetId, path, opts)) {
1288
+ try {
1289
+ durableDelete(path);
1290
+ } catch { /* the returned error already tells the caller to look */ }
1291
+ }
1292
+ return result;
1293
+ }
1294
+ // commit()'s success path already re-reads the full snapshot with the injected
1295
+ // variant directory, so the result lists the file just written.
1296
+ return result;
1297
+ }
1298
+
979
1299
  /** Replace the whole custom-layer list; order is composition order. */
980
1300
  export function writeCustomLayers(layers: readonly CustomLayer[], revision: string, opts?: Paths): WriteResult {
981
1301
  for (const layer of layers) {
@@ -1171,3 +1491,46 @@ export function salvageProjection(revision: string, opts?: Paths): WriteResult {
1171
1491
  };
1172
1492
  });
1173
1493
  }
1494
+
1495
+ /**
1496
+ * Recovery as a first-class operation, under the same file lock every mutation
1497
+ * takes. This is the journal-present repair: it replays the journal and writes
1498
+ * NOTHING else.
1499
+ *
1500
+ * A byte-identical PUT used to stand in for it, and it was not safe. When the
1501
+ * store file is missing, the snapshot's custom list reads [], so the "no-op"
1502
+ * write projected an empty developer_instructions over whatever the config
1503
+ * still carried — surviving instructions erased by the very repair that was
1504
+ * supposed to touch nothing. Recovery alone preserves exactly the bytes it can
1505
+ * account for — post where the pair proves the commit landed, pre where it
1506
+ * proves it did not — and refuses rather than guess when a target matches
1507
+ * neither image.
1508
+ */
1509
+ export function recoverPromptJournal(opts?: Paths): WriteResult {
1510
+ const configPath = activeConfigPath(opts);
1511
+ const storePath = activeStorePath(opts);
1512
+ const journalPath = journalPathFor(storePath);
1513
+
1514
+ for (const path of [configPath, storePath]) prepareCodexHome(dirname(path), 0o700);
1515
+
1516
+ const acquired = tryAcquire(lockPathFor(storePath));
1517
+ if (!acquired.ok) return { ok: false, error: acquired.error, ...(acquired.error === "unsafe" ? { detail: configWriteLockFailureMessage(acquired) } : {}) };
1518
+ const handle = acquired.handle;
1519
+
1520
+ try {
1521
+ // Use the canonical destination witness and explicit held handle used by
1522
+ // normal commits; journal replay must not reacquire its own live lock.
1523
+ const configLocked = withConfigWriteLockHeld(configPath, undefined, configHandle => {
1524
+ watchConfigWriteTargets(configHandle, [storePath, journalPath]);
1525
+ const recovered = recoverJournal(journalPath, { configPath, storePath }, configHandle);
1526
+ if (!recovered.ok) return { ok: false, error: recovered.error, detail: recovered.detail };
1527
+ return {
1528
+ ok: true,
1529
+ // Action "none" means there was no journal to close or replay.
1530
+ changed: recovered.action !== "none",
1531
+ snapshot: readPromptLayers({ ...opts, configPath, storePath }),
1532
+ };
1533
+ });
1534
+ return configLocked.ok ? configLocked.value as WriteResult : { ok: false, error: configLocked.error === "locked" ? "locked" : "unsafe", ...(configLocked.error !== "locked" ? { detail: configWriteLockFailureMessage(configLocked) } : {}) };
1535
+ } finally { release(handle); }
1536
+ }
@@ -1,6 +1,6 @@
1
1
  import { linkSync, mkdirSync, readdirSync, readFileSync, renameSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
- import { ownEvidence, ownerState, safeNamespace, type OwnerDeps, type OwnerEvidence } from "./prompt-lock-owner";
3
+ import { ownEvidence, ownerState, safeNamespace, safeNamespaceStat, type OwnerDeps, type OwnerEvidence } from "./prompt-lock-owner";
4
4
  import { ChangedLock as ChangedClaim, checkEntry, checkIdentity, guarded, type Entry, type Namespace } from "./prompt-lock-evidence";
5
5
  import { LockFileBusy, lockFileOperation } from "./prompt-lock-io";
6
6
 
@@ -41,8 +41,18 @@ export function withLockClaim<T>(
41
41
  if (error instanceof LockFileBusy) return { ok: false };
42
42
  if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
43
43
  }
44
- if (!safeNamespace(directory, "directory", deps)) throw new UnsafeLockNamespace(directory);
45
- const parents = [root, { path: directory, stat: io(() => deps.lstat(directory)) }];
44
+ // A peer's cleanup removes the empty claims directory, so it can vanish between our mkdir and
45
+ // this read. That is contention, not an unsafe namespace and not a crash: report it as busy.
46
+ // One observation is both validated and kept as the parent witness, so a replacement that
47
+ // appears after a missing read can never be trusted without its own ownership check.
48
+ let directoryStat: ReturnType<OwnerDeps["lstat"]>;
49
+ try { directoryStat = io(() => deps.lstat(directory)); }
50
+ catch (error) {
51
+ if (error instanceof LockFileBusy || (error as NodeJS.ErrnoException).code === "ENOENT") return { ok: false };
52
+ throw error;
53
+ }
54
+ if (!safeNamespaceStat(directoryStat, "directory", deps)) throw new UnsafeLockNamespace(directory);
55
+ const parents = [root, { path: directory, stat: directoryStat }];
46
56
  const claimDirectoryGone = (): boolean => {
47
57
  try { const now = deps.lstat(directory); return now.dev !== parents[1].stat.dev || now.ino !== parents[1].stat.ino; }
48
58
  catch { return true; }
@@ -1,9 +1,9 @@
1
- import { readFileSync, type Stats } from "node:fs";
2
- import { type OwnerDeps } from "./prompt-lock-owner";
1
+ import { readFileSync } from "node:fs";
2
+ import { type LockStat, type OwnerDeps } from "./prompt-lock-owner";
3
3
  import { LockFileBusy, lockFileOperation } from "./prompt-lock-io";
4
4
 
5
5
  export class ChangedLock extends LockFileBusy {}
6
- export interface Namespace { path: string; stat: Stats }
6
+ export interface Namespace { path: string; stat: LockStat }
7
7
  export interface Entry extends Namespace { body: string }
8
8
  export function checkIdentity(saved: Namespace, deps: OwnerDeps): void {
9
9
  const current = deps.lstat(saved.path);