@bitkyc08/opencodex 2.83.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 (216) 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-DiJO6sYY.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/ollama-native.ts +29 -1
  10. package/src/adapters/openai-chat/messages.ts +8 -4
  11. package/src/adapters/openai-responses/empty-assistant.ts +32 -0
  12. package/src/adapters/openai-responses/passthrough.ts +3 -0
  13. package/src/adapters/openai-responses/request-strips.ts +10 -2
  14. package/src/adapters/qoder/adapter.ts +16 -14
  15. package/src/bridge/errors.ts +19 -0
  16. package/src/bridge.ts +1 -1
  17. package/src/claude/context-windows.ts +54 -1
  18. package/src/claude/intercept/connect-proxy.ts +10 -3
  19. package/src/claude/intercept/picker-ca-persistence.ts +3 -1
  20. package/src/claude/intercept/picker-listener.ts +49 -1
  21. package/src/claude/intercept/settings-no-proxy.ts +146 -0
  22. package/src/claude/intercept/settings.ts +101 -9
  23. package/src/cli/account-api.ts +65 -0
  24. package/src/cli/account-auth.ts +26 -2
  25. package/src/cli/account.ts +41 -5
  26. package/src/cli/capabilities-access-remote.ts +8 -0
  27. package/src/cli/capabilities-base.ts +13 -1
  28. package/src/cli/catalog-command-result.ts +2 -1
  29. package/src/cli/claude.ts +15 -6
  30. package/src/cli/config-command.ts +8 -2
  31. package/src/cli/debug.ts +8 -0
  32. package/src/cli/dispatch.ts +16 -3
  33. package/src/cli/export-command.ts +14 -1
  34. package/src/cli/hub-disable.ts +99 -0
  35. package/src/cli/hub.ts +4 -2
  36. package/src/cli/index.ts +13 -4
  37. package/src/cli/link-child.ts +104 -0
  38. package/src/cli/link-enrollment.ts +96 -0
  39. package/src/cli/link.ts +39 -24
  40. package/src/cli/observe-stream.ts +6 -1
  41. package/src/cli/observe.ts +10 -2
  42. package/src/cli/provider.ts +12 -0
  43. package/src/cli/registry.ts +5 -4
  44. package/src/cli/runtime-api.ts +27 -2
  45. package/src/cli/status.ts +104 -20
  46. package/src/client/connect.ts +39 -4
  47. package/src/client/hub-client.ts +14 -7
  48. package/src/client/hub-state.ts +25 -14
  49. package/src/client/link-home.ts +248 -0
  50. package/src/client/link-management-relay.ts +61 -0
  51. package/src/client/link-relay.ts +6 -1
  52. package/src/client/link-status.ts +14 -0
  53. package/src/client/machine-api.ts +14 -7
  54. package/src/client/machine-listener.ts +29 -4
  55. package/src/clients/config-export.ts +46 -16
  56. package/src/clients/omo-role-models.ts +6 -3
  57. package/src/codex/agent-role-models.ts +26 -8
  58. package/src/codex/auth-api/account-list.ts +17 -2
  59. package/src/codex/auth-api/account-selection.ts +17 -7
  60. package/src/codex/auth-api/routes.ts +5 -1
  61. package/src/codex/autostart-health.ts +3 -1
  62. package/src/codex/catalog/bundled.ts +5 -1
  63. package/src/codex/catalog/effort.ts +2 -2
  64. package/src/codex/catalog/metadata.ts +4 -2
  65. package/src/codex/catalog/native-models.ts +16 -2
  66. package/src/codex/native-profile-startup.ts +4 -1
  67. package/src/codex/prompt-journal.ts +20 -2
  68. package/src/codex/prompt-layers/encoding.ts +49 -0
  69. package/src/codex/prompt-layers/import-source.ts +30 -0
  70. package/src/codex/prompt-layers/toml-edit.ts +269 -37
  71. package/src/codex/prompt-layers/toml-read.ts +52 -4
  72. package/src/codex/prompt-layers.ts +383 -20
  73. package/src/codex/prompt-lock-claim.ts +13 -3
  74. package/src/codex/prompt-lock-evidence.ts +3 -3
  75. package/src/codex/prompt-lock-owner.ts +14 -6
  76. package/src/codex/prompt-text-probe.ts +34 -5
  77. package/src/codex/quota.ts +13 -6
  78. package/src/codex/role-auto-assign.ts +2 -5
  79. package/src/codex/routing/active-account.ts +21 -0
  80. package/src/codex/routing.ts +1 -1
  81. package/src/codex/runtime.ts +196 -8
  82. package/src/codex/shim-overlay.ts +8 -4
  83. package/src/codex/shim.ts +1 -1
  84. package/src/combos/failover.ts +3 -0
  85. package/src/config/api-key-reconcile.ts +34 -0
  86. package/src/config/diagnostics.ts +16 -0
  87. package/src/config/feature-flags.ts +5 -0
  88. package/src/config/live-reconcile.ts +43 -29
  89. package/src/config/live-topology.ts +29 -0
  90. package/src/config/load-degrade.ts +2 -1
  91. package/src/config/paths.ts +3 -0
  92. package/src/config/salvage.ts +3 -1
  93. package/src/config/schema/config-schema.ts +2 -0
  94. package/src/generated/compatibility-version.json +311 -171
  95. package/src/grok/grpc-web.ts +23 -1
  96. package/src/grok/reset-coupon-ledger.ts +75 -5
  97. package/src/grok/reset-coupons.ts +4 -2
  98. package/src/integrations/mutation-plan.ts +13 -7
  99. package/src/integrations/registry.ts +45 -11
  100. package/src/integrations/state.ts +19 -7
  101. package/src/integrations/target.ts +42 -5
  102. package/src/integrations/writer.ts +14 -6
  103. package/src/lib/app-owned-memory-stores.ts +4 -0
  104. package/src/lib/app-owned-memory.ts +11 -0
  105. package/src/lib/json-byte-size.ts +31 -0
  106. package/src/lib/optional-shutdown-hooks.ts +35 -0
  107. package/src/lib/outbound-credential-registry.ts +210 -0
  108. package/src/lib/passive-read.ts +35 -0
  109. package/src/lib/shadow-call.ts +44 -5
  110. package/src/link/codex-settings.ts +155 -0
  111. package/src/link/management-routes.ts +89 -0
  112. package/src/link/remote-probe.ts +38 -0
  113. package/src/link/ssh-argv.ts +26 -5
  114. package/src/link/ssh-runner.ts +58 -27
  115. package/src/link/status-projection.ts +22 -4
  116. package/src/link/supervisor.ts +144 -49
  117. package/src/link/tunnel-state.ts +11 -4
  118. package/src/oauth/anthropic-continuity.ts +4 -3
  119. package/src/oauth/anthropic-identity.ts +13 -2
  120. package/src/oauth/anthropic-model-quota.ts +13 -1
  121. package/src/oauth/anthropic-routing.ts +230 -23
  122. package/src/oauth/anthropic.ts +18 -4
  123. package/src/oauth/google-antigravity.ts +24 -7
  124. package/src/oauth/index.ts +24 -15
  125. package/src/oauth/kiro-account-identity.ts +106 -0
  126. package/src/oauth/kiro.ts +48 -5
  127. package/src/oauth/plan.ts +29 -0
  128. package/src/oauth/store.ts +40 -3
  129. package/src/oauth/types.ts +2 -0
  130. package/src/plugins/loader.ts +11 -0
  131. package/src/providers/anthropic-billing-ownership.ts +74 -0
  132. package/src/providers/anthropic-billing-recovery.ts +31 -0
  133. package/src/providers/anthropic-billing-refusal.ts +55 -0
  134. package/src/providers/anthropic-reset-admission.ts +62 -0
  135. package/src/providers/anthropic-reset-grant-ledger.ts +28 -6
  136. package/src/providers/api-key-resolve.ts +17 -2
  137. package/src/providers/key-failover.ts +52 -5
  138. package/src/providers/model-rename-startup.ts +5 -3
  139. package/src/providers/quota/account-cache.ts +11 -0
  140. package/src/providers/quota/anthropic-account-quota.ts +4 -2
  141. package/src/providers/quota/report-cache.ts +14 -0
  142. package/src/providers/quota/vendor-probes-oauth.ts +3 -3
  143. package/src/providers/quota.ts +4 -1
  144. package/src/providers/registry/entries-core.ts +14 -14
  145. package/src/providers/registry/entries-extended.ts +0 -1
  146. package/src/providers/registry/model-seeds.ts +17 -0
  147. package/src/providers/request-pacing.ts +9 -0
  148. package/src/providers/stale-no-vision-seed-migration.ts +72 -0
  149. package/src/responses/compaction.ts +14 -0
  150. package/src/responses/parser.ts +7 -1
  151. package/src/server/auth-cors.ts +42 -0
  152. package/src/server/configured-credentials.ts +194 -0
  153. package/src/server/index/link-management.ts +58 -0
  154. package/src/server/index/optional-listeners.ts +5 -1
  155. package/src/server/index/serve-options.ts +13 -0
  156. package/src/server/lifecycle.ts +2 -1
  157. package/src/server/management/agent-settings-routes.ts +28 -10
  158. package/src/server/management/anthropic-reset-grant-routes.ts +17 -1
  159. package/src/server/management/codex-agent-role-routes.ts +19 -6
  160. package/src/server/management/codex-prompt-routes.ts +136 -5
  161. package/src/server/management/codex-role-auto-assign.ts +1 -1
  162. package/src/server/management/grok-coupon-routes.ts +78 -27
  163. package/src/server/management/link-enrollment.ts +192 -0
  164. package/src/server/management/link-route-state.ts +185 -0
  165. package/src/server/management/link-routes.ts +82 -402
  166. package/src/server/management/model-rows.ts +3 -1
  167. package/src/server/management/provider-overwrite-carry.ts +10 -0
  168. package/src/server/management/provider-routes.ts +1 -0
  169. package/src/server/management/route-registry.ts +5 -3
  170. package/src/server/management/shared.ts +3 -0
  171. package/src/server/management-api.ts +3 -1
  172. package/src/server/messages-native.ts +68 -42
  173. package/src/server/relay-eager.ts +2 -0
  174. package/src/server/request-log-filter.ts +6 -1
  175. package/src/server/request-log-terminal-status.ts +63 -6
  176. package/src/server/responses/adapter-dispatch.ts +232 -79
  177. package/src/server/responses/code-mode-collab-surface.ts +205 -0
  178. package/src/server/responses/codex-ws-exchange.ts +147 -6
  179. package/src/server/responses/codex-ws-wire.ts +203 -0
  180. package/src/server/responses/collaboration.ts +36 -7
  181. package/src/server/responses/compact.ts +126 -48
  182. package/src/server/responses/context-overflow.ts +13 -1
  183. package/src/server/responses/core-codex-account.ts +105 -1
  184. package/src/server/responses/core-combo-failure.ts +14 -0
  185. package/src/server/responses/core-combo.ts +7 -2
  186. package/src/server/responses/core-errors.ts +29 -1
  187. package/src/server/responses/core-options.ts +2 -0
  188. package/src/server/responses/deferred-collab-surface.ts +158 -0
  189. package/src/server/responses/deferred-send-reporter.ts +25 -0
  190. package/src/server/responses/fetch-helpers.ts +12 -1
  191. package/src/server/responses/passthrough-delivery.ts +4 -1
  192. package/src/server/responses/passthrough-dispatch.ts +15 -4
  193. package/src/server/responses/request-prepare.ts +6 -4
  194. package/src/server/responses/request-transport.ts +6 -8
  195. package/src/server/responses/sidecar-execution.ts +11 -2
  196. package/src/server/responses/ws-upstream.ts +9 -1
  197. package/src/server/startup-action-control.ts +22 -2
  198. package/src/server/startup-health-cache.ts +4 -0
  199. package/src/server/system-env-shell.ts +7 -2
  200. package/src/server/system-env.ts +11 -1
  201. package/src/service/cli.ts +5 -1
  202. package/src/service/desktop-startup.ts +147 -34
  203. package/src/service/desktop-supervision.d.mts +27 -1
  204. package/src/service/desktop-supervision.mjs +111 -5
  205. package/src/service/health-sentinel-launch.ts +176 -0
  206. package/src/service/health-sentinel-main.ts +447 -0
  207. package/src/service/health-sentinel-policy.ts +241 -0
  208. package/src/service/health-sentinel-probe.ts +54 -0
  209. package/src/service/health-sentinel-windows.ts +87 -0
  210. package/src/service/linux-linger.ts +20 -0
  211. package/src/types/config.ts +5 -0
  212. package/src/usage/expected-prices.ts +4 -0
  213. package/src/usage/log.ts +19 -1
  214. package/gui/dist/assets/App-Ba8uG696.js +0 -54
  215. package/gui/dist/assets/Tray-IImbNPRy.js +0 -1
  216. package/gui/dist/assets/index-BHRZdJuv.js +0 -94
@@ -23,6 +23,8 @@ import { projectClientLinkChild, type ClientLinkSidecarRead } from "./link-statu
23
23
  import type { ClientLinkSupervisorStatus } from "./link-tunnel";
24
24
  import { packageVersion } from "../lib/package-version";
25
25
  import { linkRouteAllowed } from "../link/routes";
26
+ import { linkHomeName, linkHomePhrase } from "./link-home";
27
+ import { relayLinkManagementRead } from "./link-management-relay";
26
28
 
27
29
  const VERSION = packageVersion("0.0.0");
28
30
  const GUI_SPA_PATHS = new Set([
@@ -100,6 +102,8 @@ export function startMachineListener(
100
102
  if (linkMode && !connection.link) throw new Error("link machine listener requires link transport metadata");
101
103
  const managementAuth = deps.managementAuthState ?? initializeManagementAuthState(config);
102
104
  let hubReachability: HubReachability = "unknown";
105
+ const readSidecar = deps.readSidecar ?? (() => readClientLinkState());
106
+ const sidecarOrNull = () => { const read = readSidecarSafely(readSidecar); return read === "invalid" ? null : read; };
103
107
  const machineApiDeps: MachineApiDeps = {
104
108
  sync: deps.machineApi?.sync ?? syncConnectedClient,
105
109
  disconnect: deps.machineApi?.disconnect ?? disconnectClient,
@@ -108,8 +112,11 @@ export function startMachineListener(
108
112
  }),
109
113
  hubReachability: deps.machineApi?.hubReachability ?? (() => hubReachability),
110
114
  setHubReachability: deps.machineApi?.setHubReachability ?? (value => { hubReachability = value; }),
115
+ ...(linkMode ? { linkHomeName: () => linkHomeName(connection, sidecarOrNull) } : {}),
111
116
  };
112
- const relayEnabled = !linkMode && connection.managementTransport === "relay";
117
+ // A link Child relays its dashboard's management reads to the Home over the link (#6957);
118
+ // a hub client relays over its management transport when that is `relay`.
119
+ const relayEnabled = linkMode || connection.managementTransport === "relay";
113
120
  // Link mode only. Everything is resolved once here, so a relayed request reads no file and
114
121
  // builds no policy: the key is cached, the loopback policy is fixed at bind like the listener.
115
122
  const inboundBodyLimit = resolveInboundBodyLimitBytes(config.maxInboundBodyBytes);
@@ -123,8 +130,6 @@ export function startMachineListener(
123
130
  relay: { fetchImpl: deps.fetchImpl, bodyLimitBytes: inboundBodyLimit, tunnel: deps.linkTunnel, ...deps.linkRelay },
124
131
  }
125
132
  : null;
126
- const readSidecar = deps.readSidecar ?? (() => readClientLinkState());
127
-
128
133
  return (deps.serve ?? (options => Bun.serve(options)))({
129
134
  port: port ?? config.port ?? 10100,
130
135
  hostname: "127.0.0.1",
@@ -147,9 +152,29 @@ export function startMachineListener(
147
152
  }
148
153
  if (url.pathname.startsWith("/api/machine/hub-relay/")) {
149
154
  if (!relayEnabled) return json404(req);
155
+ const prefix = "/api/machine/hub-relay";
156
+ if (linkIngress) {
157
+ const suffix = `${url.pathname.slice(prefix.length)}${url.search}`;
158
+ // The dashboard's shared plane bootstraps here. On a link Child that plane is this
159
+ // listener, so it receives this listener's own session; no Home credential exists.
160
+ if (suffix === "/opencodex-session") {
161
+ if (req.method !== "GET") return json404(req);
162
+ const session = issueGuiSession(req, config, managementAuth, { trustedTailscaleIngress: false });
163
+ return session ? serveSessionBootstrap(session) : Response.json({ error: "session unavailable" }, { status: 503 });
164
+ }
165
+ const authError = requireManagementAuth(req, managementAuth, config);
166
+ if (authError) return authError;
167
+ if (managementPrincipal(req, managementAuth, config) !== "gui-session") {
168
+ return Response.json({ error: "opencodex machine GUI session required" }, { status: 401 });
169
+ }
170
+ const response = await relayLinkManagementRead(req, suffix, linkIngress, linkHomePhrase(connection, sidecarOrNull));
171
+ if (response.status === 401) hubReachability = "unauthorized";
172
+ else if (response.status >= 500) hubReachability = "offline";
173
+ else if (response.status !== 403) hubReachability = "online";
174
+ return response;
175
+ }
150
176
  const authError = requireMachineAuth(req, managementAuth, config);
151
177
  if (authError) return authError;
152
- const prefix = "/api/machine/hub-relay";
153
178
  const suffix = `${url.pathname.slice(prefix.length)}${url.search}`;
154
179
  const response = await relayHubManagementRequest(req, suffix, {
155
180
  managementUrl: connection.managementUrl,
@@ -470,20 +470,51 @@ export function mcodeConfigPath(env: OpencodeLaunchEnv = process.env, home: stri
470
470
  return join(mcodeHomeDir(env, home), "config.yaml");
471
471
  }
472
472
 
473
- /**
474
- * ZCode (Z.ai's desktop client) keeps everything under `~/.zcode`; custom
475
- * providers live in `v2/config.json`. `ZCODE_DATA_DIR` mirrors the other
476
- * clients' override convention; relative overrides are refused for the same
477
- * reason as MCode's.
478
- */
479
- export function zcodeHomeDir(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string {
480
- const override = env.ZCODE_DATA_DIR?.trim();
481
- if (override) return absoluteClientPath(override, home, "ZCODE_DATA_DIR");
482
- return join(home, ".zcode");
473
+ /** Bootstrap settings always stay at the default home, even after relocation. */
474
+ export type ZcodePathIO = {
475
+ readText: (path: string) => { kind: "text"; text: string } | { kind: "missing" } | { kind: "failed"; code?: string };
476
+ };
477
+ const zcodePathIO: ZcodePathIO = {
478
+ readText: path => {
479
+ try { return { kind: "text", text: readFileSync(path, "utf8") }; }
480
+ catch (error) { return (error as NodeJS.ErrnoException).code === "ENOENT" ? { kind: "missing" } : { kind: "failed" }; }
481
+ },
482
+ };
483
+ export class ZcodeSettingsPathError extends ClientPathError {}
484
+
485
+ export function zcodeBootstrapSettingsPath(home: string = homedir()): string {
486
+ return join(home, ".zcode", "v2", "setting.json");
483
487
  }
484
488
 
485
- export function zcodeConfigPath(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string {
486
- return join(zcodeHomeDir(env, home), "v2", "config.json");
489
+ /** The pre-relocation path is retained solely to recognize historical ownership. */
490
+ export function zcodeLegacyHomeDir(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string {
491
+ const override = env.ZCODE_DATA_DIR?.trim();
492
+ return override ? absoluteClientPath(override, home, "ZCODE_DATA_DIR") : join(home, ".zcode");
493
+ }
494
+
495
+ /** ZCODE_DATA_DIR names the .zcode root; dataBaseDir names its parent. */
496
+ export function zcodeHomeDir(env: OpencodeLaunchEnv = process.env, home: string = homedir(), io: ZcodePathIO = zcodePathIO): string {
497
+ if (env.ZCODE_DATA_DIR?.trim()) return zcodeLegacyHomeDir(env, home);
498
+ const bootstrap = zcodeBootstrapSettingsPath(home);
499
+ const read = io.readText(bootstrap);
500
+ if (read.kind === "missing") return join(home, ".zcode");
501
+ const unknown = () => new ZcodeSettingsPathError(
502
+ `ZCode's active data root is unknown: ${bootstrap} must be readable JSON with an absolute dataBaseDir (or no dataBaseDir). Repair the bootstrap setting before enabling or refreshing; no legacy provider will be applied.`,
503
+ );
504
+ if (read.kind !== "text") throw unknown();
505
+ let settings: unknown;
506
+ try { settings = JSON.parse(read.text); } catch { throw unknown(); }
507
+ if (settings === null || typeof settings !== "object" || Array.isArray(settings)) throw unknown();
508
+ const base = (settings as Record<string, unknown>).dataBaseDir;
509
+ if (base === undefined) return join(home, ".zcode");
510
+ // Match the native path's value, without cwd or tilde expansion. The native
511
+ // ZCODE_DATA_BASE_DIR selector is not an alias for our ZCODE_DATA_DIR.
512
+ if (typeof base !== "string" || !isAbsolute(base) || base.includes("\0")) throw unknown();
513
+ return join(base, ".zcode");
514
+ }
515
+
516
+ export function zcodeConfigPath(env: OpencodeLaunchEnv = process.env, home: string = homedir(), io?: ZcodePathIO): string {
517
+ return join(zcodeHomeDir(env, home, io), "v2", "config.json");
487
518
  }
488
519
 
489
520
  /**
@@ -532,8 +563,7 @@ export function commandCodeConfigPath(env: OpencodeLaunchEnv = process.env, home
532
563
  * an install that has ever run, the import has already happened and never runs
533
564
  * again — every later write to `v2/config.json` is read by nobody (#5348).
534
565
  *
535
- * This project does not write this file; it names it so the integration can
536
- * tell whether its own write can still reach the client. The env override is
566
+ * The integration writes this store through its existing schema-aware writer. The env override is
537
567
  * ZCode's own (`ZCODE_PERSONAL_PROVIDER_CONFIG_FILE`), so an operator who
538
568
  * relocated the store is measured against the file their client actually opens
539
569
  * rather than the default location. A relative override is refused for the same
@@ -541,10 +571,10 @@ export function commandCodeConfigPath(env: OpencodeLaunchEnv = process.env, home
541
571
  * which file it names, and here that disagreement decides whether an apply is
542
572
  * reported as effective.
543
573
  */
544
- export function zcodeProviderStorePath(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string {
574
+ export function zcodeProviderStorePath(env: OpencodeLaunchEnv = process.env, home: string = homedir(), io?: ZcodePathIO): string {
545
575
  const override = env.ZCODE_PERSONAL_PROVIDER_CONFIG_FILE?.trim();
546
576
  if (override) return absoluteClientPath(override, home, "ZCODE_PERSONAL_PROVIDER_CONFIG_FILE");
547
- return join(zcodeHomeDir(env, home), "v2", "provider_config.json");
577
+ return join(zcodeHomeDir(env, home, io), "v2", "provider_config.json");
548
578
  }
549
579
 
550
580
  /**
@@ -120,10 +120,13 @@ function serialize(text: string, doc: JsonObject): string {
120
120
  /**
121
121
  * `reasoning` left undefined keeps the entry's level; null removes it, so a level LazyCodex cannot
122
122
  * express does not leave a stale one behind for its next install to apply.
123
+ * `model` left undefined keeps the entry's model, so an effort-only save of a role that inherits
124
+ * its model never adds a pin here. LazyCodex declares both keys optional, so an entry may carry
125
+ * `reasoning` alone; an absent entry is created only when there is a value to put in it.
123
126
  */
124
127
  export function writeOmoRoleModel(
125
128
  role: string,
126
- model: string,
129
+ model: string | undefined,
127
130
  path: string = omoJsoncPath(),
128
131
  reasoning?: string | null,
129
132
  ): OmoRoleModelWriteStatus {
@@ -137,10 +140,10 @@ export function writeOmoRoleModel(
137
140
  if (!isObject(agents)) return "invalid";
138
141
  const entry = agents[role] === undefined ? {} : agents[role];
139
142
  if (!isObject(entry)) return "invalid";
140
- const next: JsonObject = { ...entry, model };
143
+ const next: JsonObject = model === undefined ? { ...entry } : { ...entry, model };
141
144
  if (reasoning === null) delete next.reasoning;
142
145
  else if (reasoning !== undefined) next.reasoning = reasoning;
143
- if (entry.model === model && entry.reasoning === next.reasoning) return "unchanged";
146
+ if (entry.model === next.model && entry.reasoning === next.reasoning) return "unchanged";
144
147
  agents[role] = next;
145
148
  codex.agents = agents;
146
149
  doc[CODEX_SECTION] = codex;
@@ -1,11 +1,12 @@
1
1
  /**
2
- * The dashboard's write of one Codex agent role's model, and optionally its reasoning effort.
2
+ * The dashboard's write of one Codex agent role's model, its reasoning effort, or both.
3
3
  *
4
4
  * Codex overrides the spawn-time model with the root `model` pin in
5
5
  * `$CODEX_HOME/agents/<role>.toml`, so that pin is what decides a child's model. This module
6
- * edits exactly that value, plus the root `model_reasoning_effort` when a caller passes one, and
7
- * nothing else: every other byte of the file, including the instructions multiline string that
8
- * usually mentions `model =` in prose, is left as it was.
6
+ * edits exactly that value when a caller passes a model, plus the root `model_reasoning_effort`
7
+ * when a caller passes an effort, and nothing else: every other byte of the file, including the
8
+ * instructions multiline string that usually mentions `model =` in prose, is left as it was. An
9
+ * omitted model leaves the pin as it is, so a role that inherits its model keeps inheriting it.
9
10
  * It runs only on an explicit user action; no sync or startup path calls it.
10
11
  */
11
12
  import { lstatSync, readFileSync } from "node:fs";
@@ -97,6 +98,21 @@ export function setTomlRootReasoningEffort(content: string, effort: string): str
97
98
  return setTomlRootString(content, locateTomlReasoningEffortKey(content), "model_reasoning_effort", effort);
98
99
  }
99
100
 
101
+ export function removeTomlRootReasoningEffort(content: string): string {
102
+ const location = locateTomlReasoningEffortKey(content);
103
+ if (!location?.inRootTable) return content;
104
+ if (!location.span) {
105
+ throw new AgentRoleModelError("unsupported_model_value", "the existing model_reasoning_effort value is not a one-line string");
106
+ }
107
+ const lines = content.split("\n");
108
+ // A terminated line goes with its own newline. An unterminated last line is emptied instead,
109
+ // so the previous line keeps its terminator (and its \r in a CRLF file).
110
+ if (location.line < lines.length - 1) lines.splice(location.line, 1);
111
+ else lines[location.line] = "";
112
+ const bom = location.line === 0 && content.startsWith("\ufeff") ? "\ufeff" : "";
113
+ return bom + lines.join("\n");
114
+ }
115
+
100
116
  export function readCodexAgentRoleEffort(role: string, codexHome: string): string | null {
101
117
  try {
102
118
  const location = locateTomlReasoningEffortKey(readFileSync(roleFile(role, codexHome), "utf8"));
@@ -148,9 +164,9 @@ function assertValidRoleToml(role: string, text: string, stage: "before" | "afte
148
164
 
149
165
  export function writeCodexAgentRoleModel(
150
166
  role: string,
151
- model: string,
167
+ model: string | undefined,
152
168
  codexHome: string,
153
- effort?: string,
169
+ effort?: string | null,
154
170
  ): { status: "written" | "unchanged" } {
155
171
  requireKnownRole(role, codexHome);
156
172
  const path = roleFile(role, codexHome);
@@ -159,10 +175,12 @@ export function writeCodexAgentRoleModel(
159
175
  }
160
176
  const before = readFileSync(path, "utf8");
161
177
  assertValidRoleToml(role, before, "before");
162
- const withModel = setTomlRootModel(before, validateAgentRoleModel(model));
178
+ const withModel = model === undefined ? before : setTomlRootModel(before, validateAgentRoleModel(model));
163
179
  const after = effort === undefined
164
180
  ? withModel
165
- : setTomlRootReasoningEffort(withModel, validateAgentRoleEffort(effort));
181
+ : effort === null
182
+ ? removeTomlRootReasoningEffort(withModel)
183
+ : setTomlRootReasoningEffort(withModel, validateAgentRoleEffort(effort));
166
184
  if (after === before) return { status: "unchanged" };
167
185
  assertValidRoleToml(role, after, "after");
168
186
  assertIntegrationWriteOwnership(path);
@@ -33,6 +33,7 @@ import { fetchMainAccountInfoAttempt, EMPTY_MAIN_ACCOUNT_INFO, mainResetCreditsF
33
33
  import { fetchPoolAccountQuota, PoolQuotaProbeBusyError, POOL_QUOTA_REFRESH_CONCURRENCY } from "./pool-quota-probe";
34
34
  import type { PoolQuotaResult } from "./pool-quota-probe";
35
35
  import { getRuntimeConfig, configuredPoolAccount, mapWithConcurrency } from "./runtime-config";
36
+ import { isPassiveRead } from "../../lib/passive-read";
36
37
 
37
38
  export function quotaForPlan<T extends Omit<StoredAccountQuota, "updatedAt"> | StoredAccountQuota | null>(
38
39
  quota: T,
@@ -274,13 +275,25 @@ export async function listCodexAuthAccountsSnapshot(
274
275
  pruneCodexCredits([MAIN_CODEX_ACCOUNT_ID, ...poolAccounts.map(account => account.id)]);
275
276
  // One redaction decision for the whole snapshot, read once from the operator's config (#3859).
276
277
  const maskEmails = emailMaskingEnabled(runtimeConfig);
277
- const mainResult = await fetchMainAccountInfoAttempt(forceRefresh, 1, undefined, false,
278
- forceRefresh, false, runtimeConfig);
278
+ // A passive read (a Remote Link Child's, #6959) reports what this machine already observed: no
279
+ // token refresh, no WHAM probe, and so none of the plan or credential writes they make.
280
+ const passive = isPassiveRead();
281
+ const mainResult: Awaited<ReturnType<typeof fetchMainAccountInfoAttempt>> = passive
282
+ ? { info: getMainAccountInfoCache() ?? EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: false, hasCredential: false,
283
+ identityGeneration: captureMainAccountIdentityGeneration() }
284
+ : await fetchMainAccountInfoAttempt(forceRefresh, 1, undefined, false, forceRefresh, false, runtimeConfig);
279
285
  const refreshedPool = await mapWithConcurrency(poolAccounts, POOL_QUOTA_REFRESH_CONCURRENCY, async account => {
280
286
  const cred = getCodexAccountCredential(account.id);
281
287
  let quotaResult: PoolQuotaResult;
282
288
  if (!cred) {
283
289
  quotaResult = { quota: null, needsReauth: true };
290
+ } else if (passive) {
291
+ quotaResult = {
292
+ quota: getAccountQuota(account.id),
293
+ needsReauth: false,
294
+ credentialGeneration: readCodexAccountRecord(account.id)?.generation,
295
+ quotaProbeSkipped: true,
296
+ };
284
297
  } else {
285
298
  try {
286
299
  // Credits are process-local while pool quota is hydrated from disk, so right after a
@@ -410,6 +423,8 @@ export async function listCodexAuthAccountsSnapshot(
410
423
  };
411
424
  // Project the refusal and DTO in the same owned synchronous observation. A same-account grant
412
425
  // replacement during earlier pool probes must not inherit the previous grant's refusal.
426
+ // A passive read opens no claim database (its lock file is hardened on open).
427
+ if (passive) return projectSnapshot();
413
428
  const projectionLease = tryAcquireNativeMainProfileClaim();
414
429
  if (!projectionLease) return projectSnapshot();
415
430
  try {
@@ -6,11 +6,11 @@ import { deleteConfigTopLevelKey, fileBackedConfigPath, recordFileBackedConfig }
6
6
  import { MAIN_CODEX_ACCOUNT_ID, hasLegacyMainCodexPoolAccount, isSelectableCodexPoolAccount, isValidCodexAccountId } from "../account-id";
7
7
  import { isCodexAccountPaused } from "../account-pause";
8
8
  import { readCodexAccountRecord } from "../account-store";
9
- import { codexAccountPinDrainReason, resetCodexRoutingForManualSelection } from "../routing";
9
+ import { codexAccountPinDrainReason, resetCodexRoutingForClearedSelection, resetCodexRoutingForManualSelection } from "../routing";
10
10
  import { jsonResponse } from "./http";
11
11
 
12
12
  class SelectionRejected extends Error {
13
- constructor(message: string, readonly status: number) { super(message); }
13
+ constructor(message: string, readonly status: number, readonly code?: string) { super(message); }
14
14
  }
15
15
 
16
16
  function validateSelection(config: OcxConfig, accountId: string | null): void {
@@ -42,8 +42,14 @@ function applySelection(config: OcxConfig, accountId: string | null): boolean {
42
42
  return changed;
43
43
  }
44
44
 
45
+ function validateExpectedPin(config: OcxConfig, expectedPinnedAccountId: string | undefined): void {
46
+ if (expectedPinnedAccountId !== undefined && config.activeCodexAccountPinned !== expectedPinnedAccountId) {
47
+ throw new SelectionRejected("The pin changed; reload accounts before releasing it", 409, "account_pin_changed");
48
+ }
49
+ }
50
+
45
51
  /** A manual selection is an explicit command, even when equal to the live merge baseline. */
46
- export function persistCodexAccountSelection(config: OcxConfig, accountId: string | null): Response {
52
+ export function persistCodexAccountSelection(config: OcxConfig, accountId: string | null, expectedPinnedAccountId?: string): Response {
47
53
  const unknown = () => jsonResponse({
48
54
  error: "Account selection could not be confirmed; reload settings before retrying",
49
55
  code: "account_selection_unavailable",
@@ -63,7 +69,9 @@ export function persistCodexAccountSelection(config: OcxConfig, accountId: strin
63
69
  const normalized = validateConfigCandidate(mergeConfigDefaults(config));
64
70
  if (!normalized.ok) return unknown();
65
71
  const initial = normalized.config;
66
- applySelection(initial, accountId);
72
+ validateExpectedPin(initial, expectedPinnedAccountId);
73
+ // Conditional release must keep the baseline pin until the locked mutation checks it.
74
+ if (expectedPinnedAccountId === undefined) applySelection(initial, accountId);
67
75
  initializePersistedConfigIfMissing(initial);
68
76
  }
69
77
  const result = mutatePersistedConfig(persisted => {
@@ -71,12 +79,13 @@ export function persistCodexAccountSelection(config: OcxConfig, accountId: strin
71
79
  // A removed or paused account must not be resurrected by a stale UI snapshot.
72
80
  validateSelection(config, accountId);
73
81
  validateSelection(persisted, accountId);
82
+ validateExpectedPin(persisted, expectedPinnedAccountId);
74
83
  return { changed: applySelection(persisted, accountId), value: persisted };
75
84
  });
76
85
  if (result.status === "unavailable") return unknown();
77
86
  committed = result.value;
78
87
  } catch (error) {
79
- if (error instanceof SelectionRejected) return jsonResponse({ error: error.message }, error.status);
88
+ if (error instanceof SelectionRejected) return jsonResponse({ error: error.message, ...(error.code ? { code: error.code } : {}) }, error.status);
80
89
  // Atomic publication may succeed before generation/bookkeeping fails. Confirm the exact
81
90
  // durable selection instead of rolling live state back behind an already published write.
82
91
  const snapshot = readConfigFileSnapshot();
@@ -95,12 +104,13 @@ export function persistCodexAccountSelection(config: OcxConfig, accountId: strin
95
104
  adoptPersistedCodexAccountSelection(config, committed);
96
105
  recordFileBackedConfig(config, configPath);
97
106
  // No failed save may clear affinity, spend the manual preference, or move the RR cursor.
98
- resetCodexRoutingForManualSelection(accountId ?? MAIN_CODEX_ACCOUNT_ID);
107
+ if (accountId === null) resetCodexRoutingForClearedSelection();
108
+ else resetCodexRoutingForManualSelection(accountId);
99
109
  const pinDrainReason = accountId === null ? undefined : codexAccountPinDrainReason(config, accountId);
100
110
  return jsonResponse({
101
111
  ok: true,
102
112
  activeCodexAccountId: config.activeCodexAccountId ?? null,
103
- appliesImmediately: true,
113
+ appliesImmediately: accountId !== null,
104
114
  ...(pinDrainReason !== undefined ? { pinDrained: true, pinDrainReason } : {}),
105
115
  ...(warning ? { warning: "config_bookkeeping_failed" } : {}),
106
116
  });
@@ -254,7 +254,11 @@ export async function handleCodexAuthAPI(
254
254
  || !("accountId" in body) || (body.accountId !== null && typeof body.accountId !== "string")) {
255
255
  return jsonResponse({ error: "accountId must be an account id or null" }, 400);
256
256
  }
257
- return persistCodexAccountSelection(getRuntimeConfig(config), body.accountId);
257
+ const expectedPin = "expectedPinnedAccountId" in body ? body.expectedPinnedAccountId : undefined;
258
+ if (expectedPin !== undefined && (body.accountId !== null || !isCodexAccountPriorityKey(expectedPin))) {
259
+ return jsonResponse({ error: "expectedPinnedAccountId must be a valid account id for a release" }, 400);
260
+ }
261
+ return persistCodexAccountSelection(getRuntimeConfig(config), body.accountId, expectedPin as string | undefined);
258
262
  }
259
263
 
260
264
  if (url.pathname === "/api/codex-auth/active" && req.method === "GET") {
@@ -215,7 +215,9 @@ function classifyStartupHealthSummary(health: StartupHealth): string {
215
215
  ? "custom remote Codex routing (no local restart dependency)"
216
216
  : "native Codex routing (no opencodex restart dependency)";
217
217
  if (health.protection === "service") return "protected by background service";
218
- if (health.protection === "desktop") return "protected by desktop app at login and its proxy supervisor";
218
+ if (health.protection === "desktop") return health.desktop?.loginRegistration === "registered-not-loaded"
219
+ ? "protected by desktop app at next login and its proxy supervisor (Start at Login registered; not loaded in this session)"
220
+ : "protected by desktop app at login and its proxy supervisor";
219
221
  const command = health.recommendedCommand ?? health.commands.restoreNative;
220
222
  if (health.routingKind === "unknown") return `AT RISK after restart (Codex routing could not be verified; run '${command}')`;
221
223
  if (health.routingKind === "custom-local") return `AT RISK after restart (custom local gateway lifecycle is not managed by opencodex; run '${command}')`;
@@ -28,6 +28,7 @@ import {
28
28
  import type { NormalizedComboConfig } from "../../combos/types";
29
29
  import { providerDestinationResolvedError } from "../../lib/destination-policy";
30
30
  import { redactSecretString } from "../../lib/redact";
31
+ import { isPassiveRead } from "../../lib/passive-read";
31
32
  import upstreamModelsSnapshot from "../data/upstream-models.json";
32
33
  import { decodeOverlayState } from "../shim-state-file";
33
34
 
@@ -313,7 +314,8 @@ function* bundledCatalogSteps(
313
314
  const output = yield command;
314
315
  if (!current()) return null;
315
316
  const catalog = output === null ? null : parseCatalogJson(output);
316
- if (catalog && findNativeTemplate(catalog)) {
317
+ if (catalog && catalog.models?.every(row => row !== null && typeof row === "object" && !Array.isArray(row))
318
+ && findNativeTemplate(catalog)) {
317
319
  if (cacheKey) {
318
320
  publishBundledCatalogCache(
319
321
  cacheKey,
@@ -377,6 +379,8 @@ export function loadBundledCodexCatalogAsync(
377
379
  if (useCache && bundledCatalogFlight?.inputs === inputs && bundledCatalogFlight.epoch === epoch) {
378
380
  return view(bundledCatalogFlight.promise);
379
381
  }
382
+ // A passive read (a Remote Link Child's, #6959) runs no Codex binary and persists no runtime.
383
+ if (isPassiveRead()) return Promise.resolve(null);
380
384
  const controller = new AbortController();
381
385
  const flight = { inputs, epoch, controller, promise: Promise.resolve<ReadonlyRawCatalog | null>(null) };
382
386
  if (useCache && !signal && isCurrent === catalogAlwaysCurrent) {
@@ -620,8 +620,8 @@ export function clampCatalogModelsToCodexSupport(models: RawEntry[], deps: Bundl
620
620
  removedEfforts: [...clamp.removedEfforts],
621
621
  affectedModels: [...clamp.affectedModels],
622
622
  };
623
- for (const line of formatClampLogLines(diagnostic)) console.warn(line);
624
- if (!deps.commandCandidates) persistEffortClamp(diagnostic, { configDir: deps.configDir });
623
+ const changed = deps.commandCandidates ? true : persistEffortClamp(diagnostic, { configDir: deps.configDir });
624
+ if (changed) for (const line of formatClampLogLines(diagnostic)) console.warn(line);
625
625
  deps.onEffortClamp?.(diagnostic);
626
626
  } else if (!deps.commandCandidates) {
627
627
  persistEffortClamp(null, { configDir: deps.configDir });
@@ -42,6 +42,7 @@ import { RESERVE_METADATA_SOURCE_FIELD } from "./reserve";
42
42
  import {
43
43
  ACCOUNT_GATED_NATIVE_OPENAI_MODELS,
44
44
  NATIVE_GPT6_CONTEXT,
45
+ NATIVE_GPT61_SOL_CONTEXT,
45
46
  NATIVE_DAYBREAK_BLUE_MODEL,
46
47
  NATIVE_GPT6_ASTRA_MINOR_MODEL,
47
48
  NATIVE_GPT6_ASTRA_MODEL,
@@ -202,8 +203,9 @@ export const NATIVE_OPENAI_CONTEXT_OVERRIDES: Record<string, { contextWindow?: n
202
203
  // the row's own ceiling rather than the GPT-5.6 family's measured 922,000.
203
204
  [NATIVE_GPT6_SOL_MODEL]: { ...NATIVE_GPT6_CONTEXT },
204
205
  [NATIVE_GPT6_LUNA_MODEL]: { ...NATIVE_GPT6_CONTEXT },
205
- // GPT-6.1 Sol's row (openai/codex models.json after #49318) ships the same 272,000 / 872,000 pair.
206
- [NATIVE_GPT61_SOL_MODEL]: { ...NATIVE_GPT6_CONTEXT },
206
+ // GPT-6.1 Sol's row (openai/codex models.json after #49318) ships 272,000 / 872,000, but OpenAI
207
+ // raised its Codex & Work default to 500,000 on 2026-10-11; the 872,000 ceiling is unchanged.
208
+ [NATIVE_GPT61_SOL_MODEL]: { ...NATIVE_GPT61_SOL_CONTEXT },
207
209
  // Astra Minor borrows Astra's row, so it inherits Astra's numbers. No account we hold can reach
208
210
  // it, so this is inheritance, not a measurement.
209
211
  [NATIVE_GPT6_ASTRA_MINOR_MODEL]: { ...NATIVE_GPT6_CONTEXT },
@@ -44,7 +44,8 @@ export const NATIVE_GPT6_LUNA_MODEL = "gpt-6-luna";
44
44
  * SELF-DESCRIBED: its row is pinned verbatim in `src/codex/data/roster-pinned-models.json` from
45
45
  * openai/codex `codex-rs/models-manager/models.json` after #49318, which also made it the Codex
46
46
  * catalog default (priority 1). Ladder low..ultra with default effort low, and the GPT-6
47
- * 272,000 / 872,000 context pair. Ungated for the same owner decision as Sol and Luna.
47
+ * 272,000 / 872,000 context pair in that pinned row; the effective native default is now 500,000
48
+ * (NATIVE_GPT61_SOL_CONTEXT). Ungated for the same owner decision as Sol and Luna.
48
49
  */
49
50
  export const NATIVE_GPT61_SOL_MODEL = "gpt-6.1-sol";
50
51
 
@@ -62,11 +63,24 @@ export const NATIVE_GPT6_ASTRA_MINOR_MODEL = "gpt-6-astra-minor";
62
63
  /**
63
64
  * Context pair every GPT-6 native ships in its upstream row: a 272,000-token default window
64
65
  * against an 872,000-token ceiling (and input limit) reached only through the long-window opt-in.
65
- * The built-in GPT-6 rows and every configured native (below) inherit this one value.
66
+ * The built-in GPT-6 rows and every configured native (below) inherit this one value, except
67
+ * GPT-6.1 Sol, whose default window is NATIVE_GPT61_SOL_CONTEXT.
66
68
  */
67
69
  export const NATIVE_GPT6_CONTEXT: Readonly<{ contextWindow: number; maxContextWindow: number; maxInputTokens: number }> =
68
70
  Object.freeze({ contextWindow: 272_000, maxContextWindow: 872_000, maxInputTokens: 872_000 });
69
71
 
72
+ /**
73
+ * GPT-6.1 Sol through Codex & Work: OpenAI raised the default context from 272k to 500k
74
+ * (Tibo Sottiaux, 2026-10-11, https://x.com/thsottiaux/status/2109430343346581527), while
75
+ * overrides stay allowed and the model maximum is described as 1M. The bundled codex-rs row
76
+ * still reads 272,000 / 872,000, and built-in natives never take roster values, so the default
77
+ * lives here. The 872,000 ceiling (and input limit) is kept on purpose: the announcement does
78
+ * not say how "1M" maps onto the native window and input fields, and raising maxInputTokens
79
+ * would assert an input allowance nobody has measured. Overrides therefore clamp to 872,000.
80
+ */
81
+ export const NATIVE_GPT61_SOL_CONTEXT: Readonly<{ contextWindow: number; maxContextWindow: number; maxInputTokens: number }> =
82
+ Object.freeze({ ...NATIVE_GPT6_CONTEXT, contextWindow: 500_000 });
83
+
70
84
  /** Pinned row a configured native borrows its capability metadata from: the current Sol. */
71
85
  export const CONFIGURED_NATIVE_OPENAI_TEMPLATE_MODEL = NATIVE_GPT61_SOL_MODEL;
72
86
 
@@ -20,6 +20,7 @@ import { withNativeMainExclusiveClaim } from "./native-main-claim";
20
20
  import { scrubNativeMainAuthTempResidues } from "./native-main-auth-temp";
21
21
  import { NATIVE_STAGE_SWEEP_INTERVAL_MS } from "./native-profile-stage-store";
22
22
  import type { NativeCodexOwnership } from "../integrations/native/ownership-preflight";
23
+ import { isPassiveRead } from "../lib/passive-read";
23
24
 
24
25
  export type NativeMainStartupGateSnapshot =
25
26
  | { status: "ready"; homeId: string | null }
@@ -701,7 +702,9 @@ export async function releaseNativeMainStartupLifecycle(server: object): Promise
701
702
 
702
703
  export function isNativeMainTrafficBlocked(): boolean {
703
704
  const reason = activeServiceOwnershipBlockReason();
704
- if (reason !== null && reprobeServiceOwnership(reason)) {
705
+ // A passive read (a Remote Link Child's, #6959) reports the gate as it stands: re-inspecting
706
+ // service ownership can activate the native-main lifecycle and its recovery on this machine.
707
+ if (reason !== null && !isPassiveRead() && reprobeServiceOwnership(reason)) {
705
708
  // The host became ownable after boot (#2108): the fence lifts here rather than
706
709
  // waiting for the restart the reporter had to perform by hand.
707
710
  return activeServiceOwnershipBlockReason() !== null || snapshot.status === "blocked";
@@ -318,7 +318,7 @@ export function recoverIfNeeded(
318
318
  };
319
319
  }
320
320
  try {
321
- publishConfigWrite(expectedTargets.configPath, held, (destination, hooks) => restore(destination, record.preConfigBytes, hooks));
321
+ publishConfigWrite(expectedTargets.configPath, held, (destination, hooks) => restore(destination, record.preConfigBytes, expectingImage(hooks, record.postConfig)));
322
322
  } catch (error) {
323
323
  return {
324
324
  ok: false,
@@ -336,7 +336,7 @@ export function recoverIfNeeded(
336
336
  };
337
337
  }
338
338
  try {
339
- publishConfigWriteTarget(expectedTargets.configPath, held, expectedTargets.storePath, (destination, hooks) => restore(destination, record.preStoreBytes, hooks));
339
+ publishConfigWriteTarget(expectedTargets.configPath, held, expectedTargets.storePath, (destination, hooks) => restore(destination, record.preStoreBytes, expectingImage(hooks, record.postStore)));
340
340
  } catch (error) {
341
341
  return {
342
342
  ok: false,
@@ -363,3 +363,21 @@ function restore(path: string, bytes: string | null, hooks: AtomicWriteHooks): v
363
363
  if (bytes === null) durableDelete(path, hooks);
364
364
  else durableWrite(path, bytes, hooks);
365
365
  }
366
+
367
+ /**
368
+ * Bind a write to the image the caller classified. The rename retry hooks check
369
+ * path and inode identity only; an in-place edit by another process during a
370
+ * Windows sharing-violation retry keeps the inode, so without this re-read a
371
+ * later attempt would replace newer bytes. Checked before every attempt.
372
+ */
373
+ export function expectingImage(hooks: AtomicWriteHooks, expectedHash: string): AtomicWriteHooks {
374
+ return {
375
+ ...hooks,
376
+ validateBeforeRename: (targetPath: string) => {
377
+ hooks.validateBeforeRename?.(targetPath);
378
+ if (hashBytes(readOrNull(targetPath)) !== expectedHash) {
379
+ throw new Error(targetPath + " changed during the write; refusing to overwrite it. Files and recovery evidence were preserved.");
380
+ }
381
+ },
382
+ };
383
+ }
@@ -119,3 +119,52 @@ export function decodeTomlBasicString(literal: string): string | null {
119
119
  }
120
120
  return out;
121
121
  }
122
+
123
+ /** Shared scalar assignment matching; quoted and escaped keys retain their spelling. */
124
+ export function matchKeyHead(line: string, key: string): { prefix: string; rest: string } | null {
125
+ const m = /^\s*("(?:[^"\\]|\\.)*"|'[^']*'|[A-Za-z0-9_-]+)\s*=\s*/.exec(line);
126
+ if (!m) return null;
127
+ const token = m[1]!;
128
+ const decoded = token.startsWith('"') ? decodeTomlBasicString(token)
129
+ : token.startsWith("'") ? token.slice(1, -1) : token;
130
+ return decoded === key ? { prefix: m[0], rest: line.slice(m[0].length) } : null;
131
+ }
132
+
133
+ /**
134
+ * For each line, whether it begins outside every string and composite span, or
135
+ * `null` when a span never closes and the scope of later lines is unknown.
136
+ *
137
+ * Line matchers for assignments and table headers must only look at lines that
138
+ * start at top level: `"model_instructions_file" = "x"` written inside a
139
+ * `"""…"""` value or a multi-line array is prose, not syntax. Readers and
140
+ * editors share this so a write and the snapshot read back agree.
141
+ */
142
+ export function lexicalLineStarts(lines: readonly string[]): boolean[] | null {
143
+ const starts: boolean[] = [];
144
+ let open: string | null = null;
145
+ let depth = 0;
146
+ for (const line of lines) {
147
+ starts.push(open === null && depth === 0);
148
+ for (let i = 0; i < line.length; i += 1) {
149
+ const char = line[i]!;
150
+ if (open !== null) {
151
+ if (char === "\\" && (open === '"' || open === '"""')) { i += 1; continue; }
152
+ if (char !== open[0]) continue;
153
+ if (open.length === 1) { open = null; continue; }
154
+ let run = 0;
155
+ while (line[i + run] === char) run += 1;
156
+ // Up to two quotes may sit inside the value just before the closing three.
157
+ if (run >= 3) open = null;
158
+ i += run - 1;
159
+ } else if (char === "#") break;
160
+ else if (char === '"' || char === "'") {
161
+ open = line.startsWith(char.repeat(3), i) ? char.repeat(3) : char;
162
+ i += open.length - 1;
163
+ } else if (char === "[" || char === "{") depth += 1;
164
+ else if (char === "]" || char === "}") { depth -= 1; if (depth < 0) return null; }
165
+ }
166
+ // Single-line strings cannot cross a newline.
167
+ if (open !== null && open.length === 1) return null;
168
+ }
169
+ return open === null && depth === 0 ? starts : null;
170
+ }