@bitkyc08/opencodex 2.35.0 → 2.36.0-preview.20260830

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 (155) hide show
  1. package/gui/dist/assets/index-Cy7Z_pl0.css +1 -0
  2. package/gui/dist/assets/index-DPl4nBMA.js +112 -0
  3. package/gui/dist/index.html +2 -2
  4. package/package.json +2 -1
  5. package/src/AGENTS.md +2 -1
  6. package/src/adapters/agentrouter.ts +50 -0
  7. package/src/adapters/anthropic.ts +1 -51
  8. package/src/adapters/cursor/call-id.ts +76 -8
  9. package/src/adapters/cursor/checkpoint-store.ts +6 -1
  10. package/src/adapters/cursor/cursor-errors.ts +44 -0
  11. package/src/adapters/cursor/native-exec.ts +13 -0
  12. package/src/adapters/cursor/protobuf-request.ts +651 -29
  13. package/src/adapters/cursor/tool-result-normalize.ts +3 -3
  14. package/src/adapters/cursor/transport-retry.ts +5 -1
  15. package/src/adapters/cursor.ts +15 -1
  16. package/src/adapters/empty-tool-output-annotation.ts +43 -0
  17. package/src/adapters/exec-tool-result-normalize.ts +70 -5
  18. package/src/adapters/google.ts +22 -2
  19. package/src/adapters/kiro.ts +26 -2
  20. package/src/adapters/ollama-native-url.ts +111 -0
  21. package/src/adapters/ollama-native.ts +1131 -0
  22. package/src/adapters/openai-chat.ts +30 -7
  23. package/src/adapters/openai-responses.ts +72 -4
  24. package/src/adapters/registry.ts +7 -0
  25. package/src/adapters/xai-web-search.ts +58 -0
  26. package/src/claude/desktop-3p.ts +21 -1
  27. package/src/claude/desktop-policy.ts +149 -0
  28. package/src/cli/account.ts +16 -2
  29. package/src/cli/claude-desktop.ts +13 -3
  30. package/src/cli/combo.ts +8 -5
  31. package/src/cli/doctor.ts +77 -11
  32. package/src/cli/help.ts +1 -1
  33. package/src/cli/index.ts +16 -0
  34. package/src/cli/models.ts +20 -3
  35. package/src/cli/registry.ts +2 -1
  36. package/src/cli/status.ts +140 -2
  37. package/src/cli/storage.ts +10 -1
  38. package/src/codex/account-runtime-state.ts +39 -5
  39. package/src/codex/account-store.ts +393 -13
  40. package/src/codex/account-usability.ts +11 -4
  41. package/src/codex/app-server-processes.ts +46 -5
  42. package/src/codex/auth-context.ts +160 -32
  43. package/src/codex/catalog/bundled.ts +7 -5
  44. package/src/codex/catalog/metadata.ts +1 -1
  45. package/src/codex/catalog/parsing.ts +57 -1
  46. package/src/codex/catalog/provider-fetch.ts +61 -4
  47. package/src/codex/catalog/sync.ts +4 -3
  48. package/src/codex/convergence.ts +3 -2
  49. package/src/codex/data/upstream-models.json +40 -8
  50. package/src/codex/inject-coordination.ts +111 -14
  51. package/src/codex/integration-record.ts +12 -2
  52. package/src/codex/main-account.ts +225 -1
  53. package/src/codex/model-entitlements.ts +339 -27
  54. package/src/codex/prompt-layers.ts +346 -7
  55. package/src/codex/prompt-text-probe.ts +272 -21
  56. package/src/codex/routing.ts +693 -132
  57. package/src/codex/runtime.ts +12 -0
  58. package/src/codex/subagent-model-fallback.ts +62 -24
  59. package/src/codex/user-identity.ts +33 -25
  60. package/src/combos/index.ts +1 -0
  61. package/src/combos/reset-window.ts +46 -0
  62. package/src/combos/resolve.ts +84 -2
  63. package/src/combos/types.ts +5 -2
  64. package/src/config/atomic-write.ts +104 -22
  65. package/src/config/provider-validation.ts +11 -0
  66. package/src/config.ts +75 -3
  67. package/src/generated/compatibility-version.json +207 -131
  68. package/src/generated/model-metadata.ts +1 -1
  69. package/src/grok/catalog.ts +71 -0
  70. package/src/grok/effort.ts +83 -0
  71. package/src/grok/inject.ts +952 -127
  72. package/src/grok/models.ts +56 -0
  73. package/src/grok/status.ts +21 -8
  74. package/src/grok/sync.ts +10 -18
  75. package/src/images/loop.ts +6 -3
  76. package/src/integrations/native/ownership-preflight.ts +4 -1
  77. package/src/lab/fabric/producer-isolate.ts +36 -3
  78. package/src/lib/destination-policy.ts +93 -7
  79. package/src/lib/redact.ts +6 -1
  80. package/src/lib/shadow-call.ts +38 -3
  81. package/src/lib/test-home-guard.ts +18 -3
  82. package/src/lib/upstream-retry.ts +43 -6
  83. package/src/lib/windows-secret-acl.ts +66 -0
  84. package/src/lib/windows-text.ts +28 -2
  85. package/src/lib/windows-user-principal.ts +35 -23
  86. package/src/oauth/account-quota-rank.ts +107 -0
  87. package/src/oauth/anthropic-routing.ts +125 -30
  88. package/src/oauth/chatgpt.ts +5 -1
  89. package/src/oauth/generic-account-failover.ts +114 -7
  90. package/src/oauth/index.ts +15 -8
  91. package/src/oauth/store.ts +16 -0
  92. package/src/providers/account-quota-disk.ts +79 -0
  93. package/src/providers/command-code-efforts.ts +24 -0
  94. package/src/providers/derive.ts +6 -0
  95. package/src/providers/key-failover.ts +33 -1
  96. package/src/providers/kiro-usage.ts +272 -0
  97. package/src/providers/ollama-show.ts +311 -0
  98. package/src/providers/openai-sidecar.ts +5 -0
  99. package/src/providers/quota-routing-cache.ts +32 -0
  100. package/src/providers/quota-types.ts +36 -0
  101. package/src/providers/quota-wire.ts +102 -0
  102. package/src/providers/quota.ts +208 -147
  103. package/src/providers/registry.ts +68 -8
  104. package/src/providers/slug-codec.ts +12 -4
  105. package/src/providers/vercel-gateway-routing.ts +108 -0
  106. package/src/router.ts +22 -12
  107. package/src/server/auth-cors.ts +26 -0
  108. package/src/server/catalog-download.ts +73 -0
  109. package/src/server/chat-native.ts +12 -2
  110. package/src/server/gui-static.ts +4 -1
  111. package/src/server/index.ts +132 -9
  112. package/src/server/management/agent-settings-routes.ts +38 -5
  113. package/src/server/management/codex-prompt-routes.ts +7 -1
  114. package/src/server/management/combo-routes.ts +10 -1
  115. package/src/server/management/config-routes.ts +9 -1
  116. package/src/server/management/context.ts +5 -0
  117. package/src/server/management/model-routes.ts +16 -6
  118. package/src/server/management/native-integration-routes.ts +12 -17
  119. package/src/server/management/oauth-account-routes.ts +13 -0
  120. package/src/server/management/provider-routes.ts +32 -5
  121. package/src/server/management/routing-profile-routes.ts +15 -0
  122. package/src/server/management/shadow-call-validation.ts +29 -0
  123. package/src/server/management-api.ts +7 -3
  124. package/src/server/request-log.ts +3 -5
  125. package/src/server/responses/agent-task-recovery-cache.ts +8 -0
  126. package/src/server/responses/agent-task-recovery.ts +52 -20
  127. package/src/server/responses/codex-auth-error.ts +26 -0
  128. package/src/server/responses/compact.ts +345 -10
  129. package/src/server/responses/core.ts +736 -108
  130. package/src/server/responses/empty-completion-guard.ts +16 -0
  131. package/src/server/responses/fetch-helpers.ts +42 -0
  132. package/src/server/responses/policy-fallback.ts +11 -6
  133. package/src/server/responses-undeclared-tool-guard.ts +16 -3
  134. package/src/server/startup-health-cache.ts +59 -13
  135. package/src/service-manager-probe.ts +115 -9
  136. package/src/service.ts +139 -40
  137. package/src/storage/cleanup.ts +10 -0
  138. package/src/storage/storage-mutation-coordinator.ts +14 -3
  139. package/src/tray/windows-tray.ps1 +10 -4
  140. package/src/tray/windows.ts +30 -2
  141. package/src/types/config.ts +27 -14
  142. package/src/types/provider.ts +54 -0
  143. package/src/types/tools.ts +13 -3
  144. package/src/types.ts +4 -0
  145. package/src/usage/summary.ts +421 -177
  146. package/src/vision/anthropic-describe.ts +3 -3
  147. package/src/vision/describe.ts +5 -3
  148. package/src/web-search/anthropic-executor.ts +9 -2
  149. package/src/web-search/exa-executor.ts +3 -3
  150. package/src/web-search/executor.ts +8 -3
  151. package/src/web-search/gemini-executor.ts +3 -3
  152. package/src/web-search/loop.ts +11 -3
  153. package/src/web-search/xai-executor.ts +3 -3
  154. package/gui/dist/assets/index-DNdRKXK9.js +0 -112
  155. package/gui/dist/assets/index-DQ-Ie18T.css +0 -1
@@ -28,9 +28,10 @@
28
28
  */
29
29
  import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs";
30
30
  import { dirname, join, resolve } from "node:path";
31
- import { createHash, randomBytes } from "node:crypto";
31
+ import { createHash, randomBytes, type Hash } from "node:crypto";
32
32
  import { expandUserPath } from "../config";
33
33
  import { CODEX_CONFIG_PATH } from "./paths";
34
+ import { resolveCodexHomeDir } from "./home";
34
35
  import { OCX_SECTION_MARKER } from "./injected-marker";
35
36
  import {
36
37
  durableWrite,
@@ -181,6 +182,211 @@ export function activeBaseVariantDir(opts?: Paths): string {
181
182
  return opts?.baseVariantDir ?? join(activeCodexHome(), "opencodex-prompt-base");
182
183
  }
183
184
 
185
+ /**
186
+ * Instruction documents the prompt probe renders out of CODEX_HOME, in the
187
+ * precedence order Codex itself applies: an `AGENTS.override.md` shadows
188
+ * `AGENTS.md`. Both are hashed into the probe fingerprint, because either one
189
+ * changes the rendered project document without touching a managed file.
190
+ */
191
+ const PROBE_INSTRUCTION_FILES = ["AGENTS.override.md", "AGENTS.md"] as const;
192
+
193
+ /**
194
+ * The project-document filenames Codex would look for in a given home, in its own
195
+ * order: the two built-ins first, then whatever `project_doc_fallback_filenames`
196
+ * adds, de-duplicated (`core/src/agents_md.rs` `candidate_filenames`).
197
+ *
198
+ * Read from config rather than hard-coded, because a user who configures
199
+ * `TEAM.md` renders TEAM.md, and a fingerprint that only knew about AGENTS.md
200
+ * would let an edit to it pass unnoticed.
201
+ *
202
+ */
203
+ function probeInstructionFilenames(configBytes: string | null): string[] {
204
+ const names: string[] = [...PROBE_INSTRUCTION_FILES];
205
+ for (const entry of rootArrayEntries(configBytes, "project_doc_fallback_filenames")) {
206
+ // Upstream trims each configured name and drops whitespace-only entries
207
+ // (`core/src/config/mod.rs`), so " TEAM.md " and "TEAM.md" are one filename.
208
+ const name = entry.trim();
209
+ if (name === "") continue;
210
+ if (!names.includes(name)) names.push(name);
211
+ }
212
+ return names;
213
+ }
214
+
215
+ /**
216
+ * Decoded string entries of a root-scope TOML array.
217
+ *
218
+ * Parsed, not pattern-matched. Three successive review rounds each found another
219
+ * valid spelling a hand-rolled reader missed — multi-line arrays, a comment after the
220
+ * opening bracket, a quoted key — and every miss was a rendered document whose edits
221
+ * moved no admission key. The pattern was the defect: TOML is not a line format, so
222
+ * no regex over lines can enumerate what a parser accepts.
223
+ *
224
+ * The module header's warning about JS TOML parsers does apply here, and a review
225
+ * round proved it against an earlier version of this comment that claimed otherwise.
226
+ * Bun rejects an entire document containing an integer outside JavaScript's safe
227
+ * range, such as `model_context_window = 9223372036854775807`, which Rust accepts as
228
+ * an ordinary `i64`. A whole-document parse turned that into BOTH arrays disappearing
229
+ * — a worse failure than any single missed spelling, and one the old regex did not
230
+ * have.
231
+ *
232
+ * So the parse is the preferred reader, not the only one. When it fails, the scan
233
+ * below runs, and it is deliberately loose: it accepts any spelling it recognises and
234
+ * over-reports rather than under-reports, because an extra hashed filename costs one
235
+ * redundant probe while a missing one costs stale text.
236
+ */
237
+ function rootArrayEntries(configBytes: string | null, key: string): string[] {
238
+ const value = rootValue(configBytes, key);
239
+ if (value === PARSE_FAILED) return scanRootArrayEntries(configBytes, key);
240
+ if (!Array.isArray(value)) return [];
241
+ return value.filter((entry): entry is string => typeof entry === "string");
242
+ }
243
+
244
+ /**
245
+ * Distinguishes "the parser could not read this file" from "the key is absent".
246
+ * Collapsing the two is what made an unrelated large integer silently empty the
247
+ * project-document set.
248
+ */
249
+ const PARSE_FAILED = Symbol("toml-parse-failed");
250
+
251
+ /** A root-scope value, `undefined` when the key is absent, `PARSE_FAILED` when the file will not parse. */
252
+ function rootValue(configBytes: string | null, key: string): unknown {
253
+ if (configBytes === null) return undefined;
254
+ let parsed: unknown;
255
+ try {
256
+ parsed = Bun.TOML.parse(configBytes);
257
+ } catch {
258
+ return PARSE_FAILED;
259
+ }
260
+ if (typeof parsed !== "object" || parsed === null) return PARSE_FAILED;
261
+ return (parsed as Record<string, unknown>)[key];
262
+ }
263
+
264
+ /**
265
+ * Fallback reader for a config this parser will not accept but Codex will.
266
+ *
267
+ * Not a second attempt at being a TOML parser — that approach failed three review
268
+ * rounds. It is a deliberately over-eager scan: it takes the first bracketed group for
269
+ * the key under either spelling, spans lines, strips comments, and keeps anything that
270
+ * decodes. Over-reporting is the safe direction here.
271
+ */
272
+ function scanRootArrayEntries(configBytes: string | null, key: string): string[] {
273
+ const lines = rootLines(configBytes ?? "");
274
+ const opener = new RegExp(`^\\s*"?${key}"?\\s*=\\s*\\[(.*)$`);
275
+ for (let i = 0; i < lines.length; i += 1) {
276
+ const m = opener.exec(lines[i]!);
277
+ if (!m) continue;
278
+ let body = m[1]!.replace(/#.*$/, "");
279
+ for (let j = i; !body.includes("]"); ) {
280
+ j += 1;
281
+ if (j >= lines.length) return [];
282
+ body += lines[j]!.replace(/#.*$/, "");
283
+ }
284
+ const out: string[] = [];
285
+ for (const raw of body.slice(0, body.indexOf("]")).split(",")) {
286
+ const trimmed = raw.trim();
287
+ if (trimmed === "") continue;
288
+ const decoded = trimmed.startsWith("'") && trimmed.endsWith("'") && trimmed.length >= 2
289
+ ? trimmed.slice(1, -1)
290
+ : decodeBasicString(trimmed);
291
+ if (decoded !== null) out.push(decoded);
292
+ }
293
+ return out;
294
+ }
295
+ return [];
296
+ }
297
+
298
+ /**
299
+ * The directories Codex would look in for a project document, given the home the
300
+ * probe runs in.
301
+ *
302
+ * Upstream finds the nearest ancestor holding a `project_root_markers` entry
303
+ * (default `.git`) and then searches every directory from that root down to the cwd,
304
+ * inclusive; with no such ancestor it searches the cwd alone
305
+ * (`core/src/agents_md.rs` `agents_md_paths`).
306
+ *
307
+ * This was originally written off as unreachable on the grounds that the probe runs
308
+ * in CODEX_HOME with no checkout around it. That was wrong, and a review round caught
309
+ * it: `~/.codex` inside a dotfiles repository is an ordinary setup, and there the
310
+ * walk finds real documents. The walk is cheap — a bounded number of `existsSync`
311
+ * calls beside a subprocess spawn — so it is performed rather than assumed away.
312
+ */
313
+ function probeProjectDocDirs(home: string, configBytes: string | null): string[] {
314
+ const markers = projectRootMarkers(configBytes);
315
+ // An explicitly empty array disables root detection upstream, which is not the same
316
+ // as an absent key falling back to the default.
317
+ if (markers.length === 0) return [home];
318
+ let root: string | null = null;
319
+ for (let dir = home; ; ) {
320
+ if (markers.some(marker => existsSync(join(dir, marker)))) { root = dir; break; }
321
+ const parent = dirname(dir);
322
+ if (parent === dir) break;
323
+ dir = parent;
324
+ }
325
+ if (root === null) return [home];
326
+ const dirs: string[] = [];
327
+ for (let dir = home; ; ) {
328
+ dirs.push(dir);
329
+ if (dir === root) break;
330
+ const parent = dirname(dir);
331
+ if (parent === dir) break;
332
+ dir = parent;
333
+ }
334
+ // Root first, matching upstream's reversed search order. Order is load-bearing:
335
+ // the digest must not change merely because the walk was traversed the other way.
336
+ return dirs.reverse();
337
+ }
338
+
339
+ /** `project_root_markers`, defaulting to `.git` when the key is absent. */
340
+ function projectRootMarkers(configBytes: string | null): string[] {
341
+ if (!hasRootKey(configBytes, "project_root_markers")) return [".git"];
342
+ // Present-but-empty disables root detection upstream, which is why presence is
343
+ // tested separately from the decoded entries rather than inferred from them.
344
+ return rootArrayEntries(configBytes, "project_root_markers").filter(m => m !== "");
345
+ }
346
+
347
+ /**
348
+ * Whether a root-scope key is present at all, regardless of what it holds.
349
+ *
350
+ * A parse failure is not an answer, so it falls through to the scan rather than
351
+ * counting as present: reading `PARSE_FAILED` as "present" would report an empty
352
+ * marker list and disable root detection on a config Codex reads fine.
353
+ */
354
+ function hasRootKey(configBytes: string | null, key: string): boolean {
355
+ const value = rootValue(configBytes, key);
356
+ if (value === PARSE_FAILED) return scanHasRootKey(configBytes, key);
357
+ return value !== undefined;
358
+ }
359
+
360
+ /** Textual presence check, used only when the parser cannot read the file. */
361
+ function scanHasRootKey(configBytes: string | null, key: string): boolean {
362
+ const probe = new RegExp(`^\\s*"?${key}"?\\s*=`);
363
+ return rootLines(configBytes ?? "").some(line => probe.test(line));
364
+ }
365
+
366
+ /**
367
+ * Feed one named field into a fingerprint, framed so that no two distinct states
368
+ * can produce the same digest.
369
+ *
370
+ * Framing is the whole point. Concatenating `name + ":" + contents` is ambiguous:
371
+ * an adversarial review of the first version of this function showed that
372
+ * `{override: "left", agents: "right\nAGENTS.md:tail"}` and
373
+ * `{override: "left\nAGENTS.md:right", agents: "tail"}` hashed identically, because
374
+ * a file's own bytes can imitate the separator that follows it. That is exactly a
375
+ * missed invalidation: the fingerprint is the probe's admission key, so two
376
+ * different prompt states sharing a digest means one caller is served the other's
377
+ * stale text.
378
+ *
379
+ * A byte length cannot be forged by content, so each field carries one. Absence is
380
+ * a length of -1 rather than a sentinel string, because a sentinel is just more
381
+ * content: the same review found that `null` collided with a file whose bytes were
382
+ * literally NUL + "absent".
383
+ */
384
+ function updateFingerprintField(hash: Hash, name: string, contents: string | null): void {
385
+ const bytes = contents === null ? -1 : Buffer.byteLength(contents, "utf8");
386
+ hash.update(`\n${name}:${bytes}:`);
387
+ if (contents !== null) hash.update(contents);
388
+ }
389
+
184
390
  function journalPathFor(storePath: string): string {
185
391
  return `${storePath.replace(/\.json$/, "")}.journal`;
186
392
  }
@@ -286,10 +492,13 @@ function readFileOrNull(path: string): string | null {
286
492
 
287
493
  export function computeRevision(configBytes: string | null, storeBytes: string | null): string {
288
494
  const hash = createHash("sha256");
289
- hash.update("cfg:");
290
- hash.update(configBytes ?? "\0absent");
291
- hash.update("\nstore:");
292
- hash.update(storeBytes ?? "\0absent");
495
+ // Length-framed for the reason given on updateFingerprintField: with a bare
496
+ // separator, config bytes ending in "\nstore:" shift the boundary and two
497
+ // different pairs hash alike. That matters twice over — this value is both the
498
+ // probe's admission input and the optimistic-concurrency token compared in
499
+ // commit(), where a collision would let a write built on stale bytes through.
500
+ updateFingerprintField(hash, "cfg", configBytes);
501
+ updateFingerprintField(hash, "store", storeBytes);
293
502
  return `sha256:${hash.digest("hex")}`;
294
503
  }
295
504
 
@@ -506,8 +715,19 @@ function readToggle(configBytes: string | null, id: ToggleId): ToggleState {
506
715
  function readModelInstructionsFile(configBytes: string | null): string | null {
507
716
  if (configBytes === null) return null;
508
717
  for (const line of rootLines(configBytes)) {
509
- const m = /^\s*model_instructions_file\s*=\s*"([^"]*)"\s*(?:#.*)?$/.exec(line);
510
- if (m) return m[1]!;
718
+ // Capture the whole literal INCLUDING its quotes and decode it, rather than
719
+ // returning the raw inner text. `setRootString` writes this key through
720
+ // `encodeBasicString`, which escapes backslashes, so on Windows the stored
721
+ // literal is "C:\\Users\\..." while the path is "C:\Users\...". Reading the
722
+ // inner text verbatim returned the doubled form: the round trip did not
723
+ // survive, `baseSelection` compared a doubled path against the real variant
724
+ // path and reported `external` for a variant this code had just selected.
725
+ //
726
+ // `[^"]*` cannot span an escaped quote either. That is not a new limit -- it
727
+ // is the same one the writer's restricted escape set is built around, and
728
+ // `decodeBasicString` refuses anything outside it rather than guessing.
729
+ const m = /^\s*model_instructions_file\s*=\s*("[^"]*")\s*(?:#.*)?$/.exec(line);
730
+ if (m) return decodeBasicString(m[1]!);
511
731
  }
512
732
  return null;
513
733
  }
@@ -634,6 +854,125 @@ export function readPromptLayers(opts?: Paths): PromptLayerSnapshot {
634
854
  };
635
855
  }
636
856
 
857
+ /**
858
+ * Identity for prompt-text probe admission, deliberately separate from the
859
+ * optimistic-concurrency revision above. The revision covers only config/store
860
+ * transaction bytes; an edit to the selected base variant changes the prompt
861
+ * without changing that transaction contract.
862
+ *
863
+ * The instruction documents in CODEX_HOME are hashed for the same reason, and they
864
+ * are read from `resolveCodexHomeDir()` rather than from `activeConfigPath`'s
865
+ * directory. Those two are deliberately different under test — the route fixtures
866
+ * inject `codexPromptPaths` at a temp root while CODEX_HOME points at a decoy — and
867
+ * the probe renders whatever lives in the home it actually runs in. Deriving the
868
+ * path from the injected config would name a file the probe never reads, which is
869
+ * a fingerprint that cannot fail rather than evidence.
870
+ *
871
+ * A BOUNDED invalidation key, not prompt identity. It covers opencodex-managed writes,
872
+ * the selected base prompt, the project documents Codex would discover from this home,
873
+ * and each skill's manifest. Plugin manifests, live MCP availability, and the clock
874
+ * also move the rendered prompt and are not files this process can name.
875
+ *
876
+ * The distinction is worth stating exactly, because the obvious phrasing is wrong: for
877
+ * a COVERED input the key moves and a late caller is refused with `busy`. For an
878
+ * UNCOVERED one the key does not move, so a late caller joins and reads the older
879
+ * rendering. That is the residual, bounded to one in-flight window in a read-only view.
880
+ *
881
+ * "Hash every input" is only closable against a pinned Codex — the dependency graph is
882
+ * upstream's and moves on its own. An enumeration-free alternative exists (admit only
883
+ * when the probe started after the request arrived) and is recorded in the plan; it
884
+ * costs the coalescing this work exists to provide unless arrivals are batched first.
885
+ * See devlog/_plan/260829_bugpr_lane_h_residual_issues/130_pr2872_probe_fingerprint.md.
886
+ */
887
+ export function computePromptProbeStateFingerprint(opts?: Paths): string {
888
+ const configBytes = readFileOrNull(activeConfigPath(opts));
889
+ const storeBytes = readFileOrNull(activeStorePath(opts));
890
+ const variants = readBaseVariants(opts);
891
+ const selection = resolveBaseSelection(configBytes, variants, opts);
892
+ const hash = createHash("sha256");
893
+ updateFingerprintField(hash, "revision", computeRevision(configBytes, storeBytes));
894
+ updateFingerprintField(hash, "selected-base", selection.kind === "variant" ? `variant:${selection.id}` : selection.kind);
895
+ if (selection.kind === "variant") {
896
+ updateFingerprintField(hash, "variant-bytes", readFileOrNull(join(activeBaseVariantDir(opts), `${selection.id}.md`)));
897
+ }
898
+ if (selection.kind === "external") {
899
+ // The selected base file is hashed whether or not we manage it. Hashing the
900
+ // managed variant's bytes while recording an external selection as the bare
901
+ // word "external" would make the guarantee depend on who authored the file,
902
+ // which is not a distinction the probe's caller can see.
903
+ //
904
+ // Its path is part of the identity as well as its contents: pointing the key
905
+ // at a different file changes the prompt even when both files read alike.
906
+ updateFingerprintField(hash, "external-path", selection.path);
907
+ let externalBytes: string | null = null;
908
+ try {
909
+ // Relative to the CONFIG FILE's directory, which is what Codex does with its
910
+ // relative path fields. resolve() alone would use this process's cwd — the
911
+ // proxy's working directory, which has nothing to do with either the config
912
+ // or the probe child's cwd — and would hash an unrelated file.
913
+ externalBytes = readFileOrNull(resolve(dirname(activeConfigPath(opts)), expandUserPath(selection.path)));
914
+ } catch {
915
+ // An unresolvable path is a state, not a failure: it hashes as absent, and
916
+ // resolveBaseSelection has already reported the selection as external.
917
+ externalBytes = null;
918
+ }
919
+ updateFingerprintField(hash, "external-bytes", externalBytes);
920
+ }
921
+ // Codex prefers AGENTS.override.md over AGENTS.md, so both spellings are hashed
922
+ // in that order: an override edit changes the rendered project document exactly
923
+ // as a plain edit does.
924
+ const probeHome = resolveCodexHomeDir();
925
+ const filenames = probeInstructionFilenames(configBytes);
926
+ for (const dir of probeProjectDocDirs(probeHome, configBytes)) {
927
+ for (const name of filenames) {
928
+ // The path goes in the CONTENTS, never in the field name. Only contents are
929
+ // length-framed, so a name built from a path would reintroduce exactly the
930
+ // ambiguity this helper exists to remove. Path and bytes are separate fields
931
+ // because two directories in the walk can both hold an AGENTS.md.
932
+ const path = join(dir, name);
933
+ updateFingerprintField(hash, "doc-path", path);
934
+ updateFingerprintField(hash, "doc-bytes", readFileOrNull(path));
935
+ }
936
+ }
937
+ for (const path of probeSkillManifests(probeHome)) {
938
+ updateFingerprintField(hash, "skill-path", path);
939
+ updateFingerprintField(hash, "skill-bytes", readFileOrNull(path));
940
+ }
941
+ return `sha256:${hash.digest("hex")}`;
942
+ }
943
+
944
+ /**
945
+ * `SKILL.md` manifests under the home's skills directory.
946
+ *
947
+ * These were written off as unobservable in an earlier version of this function's
948
+ * comment. They are not: Codex reads each manifest's frontmatter and renders its
949
+ * description into `<skills_instructions>`, and a review round demonstrated a live
950
+ * description edit changing the probe's output while the fingerprint stood still.
951
+ *
952
+ * One directory listing plus one `readFileOrNull` per skill, beside a subprocess that
953
+ * costs orders of magnitude more. Sorted, because `readdirSync` order is not a
954
+ * contract and a digest must not depend on it.
955
+ *
956
+ * Only the top-level manifest per skill is read. A skill's bundled scripts and
957
+ * references do not reach the rendered section, so hashing the whole tree would buy
958
+ * redundant invalidations at a real cost on large skill sets.
959
+ */
960
+ function probeSkillManifests(home: string): string[] {
961
+ const root = join(home, "skills");
962
+ let entries: string[];
963
+ try {
964
+ entries = readdirSync(root);
965
+ } catch {
966
+ return [];
967
+ }
968
+ const manifests: string[] = [];
969
+ for (const entry of entries.sort()) {
970
+ const manifest = join(root, entry, "SKILL.md");
971
+ if (existsSync(manifest)) manifests.push(manifest);
972
+ }
973
+ return manifests;
974
+ }
975
+
637
976
  // ---------------------------------------------------------------------------
638
977
  // Writing
639
978
  // ---------------------------------------------------------------------------