@bitkyc08/opencodex 2.25.0 → 2.26.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 (99) hide show
  1. package/bin/ocx.mjs +59 -7
  2. package/gui/dist/assets/index-RL6b1bTV.js +102 -0
  3. package/gui/dist/index.html +1 -1
  4. package/package.json +1 -1
  5. package/src/adapters/base.ts +18 -0
  6. package/src/adapters/client-fingerprint.ts +0 -2
  7. package/src/adapters/cursor/http1-bidi.ts +361 -0
  8. package/src/adapters/cursor/live-models.ts +117 -30
  9. package/src/adapters/cursor/live-transport.ts +137 -56
  10. package/src/adapters/cursor/native-exec-fs.ts +1 -1
  11. package/src/adapters/cursor/native-exec-network.ts +1 -1
  12. package/src/adapters/cursor/native-exec-shell.ts +0 -1
  13. package/src/adapters/cursor/protobuf-request.ts +89 -21
  14. package/src/adapters/cursor/tool-definitions.ts +18 -7
  15. package/src/adapters/cursor/tool-result-normalize.ts +92 -0
  16. package/src/adapters/cursor/transport.ts +7 -0
  17. package/src/adapters/cursor.ts +2 -0
  18. package/src/adapters/google-http.ts +38 -10
  19. package/src/adapters/google.ts +23 -3
  20. package/src/adapters/openai-chat.ts +38 -13
  21. package/src/adapters/openai-responses.ts +129 -9
  22. package/src/adapters/registry.ts +30 -1
  23. package/src/bridge.ts +50 -12
  24. package/src/chat/inbound.ts +1 -0
  25. package/src/claude/agents-inject.ts +3 -2
  26. package/src/cli/dispatch.ts +19 -6
  27. package/src/cli/help.ts +2 -1
  28. package/src/cli/integrations.ts +35 -0
  29. package/src/cli/minimax.ts +8 -2
  30. package/src/cli/registry.ts +13 -2
  31. package/src/clients/config-export.ts +120 -1
  32. package/src/codex/account-store.ts +17 -1
  33. package/src/codex/auth-api.ts +51 -17
  34. package/src/codex/catalog/effort.ts +8 -5
  35. package/src/codex/catalog/provider-fetch.ts +39 -27
  36. package/src/codex/catalog/sync.ts +30 -8
  37. package/src/codex/inject.ts +39 -13
  38. package/src/codex/main-account.ts +29 -1
  39. package/src/codex/plan-from-token.ts +140 -0
  40. package/src/codex/plan.ts +25 -0
  41. package/src/codex/prompt-journal.ts +6 -2
  42. package/src/codex/quota-rejection.ts +21 -7
  43. package/src/codex/refresh.ts +4 -2
  44. package/src/codex/sync.ts +106 -3
  45. package/src/codex/warmup.ts +187 -81
  46. package/src/config.ts +85 -46
  47. package/src/generated/compatibility-version.json +127 -83
  48. package/src/grok/inject.ts +1 -1
  49. package/src/images/loop.ts +1 -0
  50. package/src/integrations/registry.ts +7 -0
  51. package/src/lab/automation/config-persistence.ts +2 -2
  52. package/src/lab/automation/persistence.ts +2 -2
  53. package/src/lab/ledger/purge.ts +2 -2
  54. package/src/lab/subject/behavior-fingerprint.ts +2 -2
  55. package/src/lib/config-ownership.ts +5 -2
  56. package/src/lib/destination-policy.ts +18 -1
  57. package/src/lib/provider-outbound.ts +7 -0
  58. package/src/lib/redact.ts +11 -0
  59. package/src/lib/tool-argument-integers.ts +50 -6
  60. package/src/lib/upstream-http-version.ts +57 -0
  61. package/src/lib/upstream-retry.ts +2 -1
  62. package/src/lib/windows-atomic-replace.ts +155 -0
  63. package/src/lib/windows-service-wrappers.ts +72 -0
  64. package/src/oauth/google-antigravity.ts +2 -2
  65. package/src/oauth/index.ts +60 -8
  66. package/src/providers/antigravity-models.ts +58 -4
  67. package/src/providers/command-code-efforts.ts +36 -9
  68. package/src/providers/context-cap.ts +9 -0
  69. package/src/providers/derive.ts +4 -0
  70. package/src/providers/fastwire.ts +453 -0
  71. package/src/providers/registry.ts +21 -1
  72. package/src/providers/service-tier.ts +173 -89
  73. package/src/responses/parser.ts +25 -16
  74. package/src/responses/reasoning-replay-cache.ts +30 -0
  75. package/src/responses/thought-signature-replay.ts +74 -4
  76. package/src/router.ts +6 -0
  77. package/src/routing/compatibility/behavior.ts +27 -16
  78. package/src/server/index.ts +10 -1
  79. package/src/server/management/oauth-account-routes.ts +10 -2
  80. package/src/server/management/shared.ts +1 -1
  81. package/src/server/management/system-routes.ts +15 -0
  82. package/src/server/request-log.ts +57 -3
  83. package/src/server/responses/agent-task-recovery.ts +6 -1
  84. package/src/server/responses/core.ts +209 -40
  85. package/src/server/responses/fetch-helpers.ts +15 -36
  86. package/src/server/responses/responses-field-backfill.ts +173 -0
  87. package/src/server/responses-reasoning-summary-rewrite.ts +171 -0
  88. package/src/service.ts +38 -36
  89. package/src/storage/cleanup.ts +2 -2
  90. package/src/tray/windows.ts +3 -2
  91. package/src/types.ts +101 -14
  92. package/src/update/job.ts +9 -18
  93. package/src/update/transactional-install.d.mts +22 -0
  94. package/src/update/transactional-install.mjs +259 -0
  95. package/src/usage/cost.ts +34 -5
  96. package/src/usage/log.ts +74 -4
  97. package/src/vision/anthropic-describe.ts +10 -6
  98. package/src/web-search/anthropic-executor.ts +8 -6
  99. package/gui/dist/assets/index-DxJ7kXj9.js +0 -102
package/src/types.ts CHANGED
@@ -14,6 +14,12 @@ export interface OcxReasoningReplayIdentity {
14
14
  modelId: string;
15
15
  /** Opaque process-local credential identity; never a raw token or API key. */
16
16
  credentialIdentity: string;
17
+ /**
18
+ * Salted-HMAC credential identity that survives restarts, for the durable
19
+ * thought-signature store (#1926). Absent when no durable identity could be
20
+ * derived — the durable store then refuses to key the entry (fail closed).
21
+ */
22
+ credentialDurableIdentity?: string;
17
23
  }
18
24
 
19
25
  /**
@@ -301,6 +307,10 @@ export interface OcxRequestOptions {
301
307
  reasoning?: string;
302
308
  hideThinkingSummary?: boolean;
303
309
  serviceTier?: string;
310
+ /** Final outbound tier action, resolved after the provider/model wire is settled. */
311
+ tierDecision?: TierDecision;
312
+ /** Internal B0 observation inputs; adapters combine these with the wire they actually serialize. */
313
+ tierObservation?: TierObservationContext;
304
314
  presencePenalty?: number;
305
315
  frequencyPenalty?: number;
306
316
  /** Responses prompt-cache affinity key. Passthrough preserves it via _rawBody; routed adapters do not consume it unless their upstream wire supports it. */
@@ -1315,6 +1325,51 @@ export interface ProviderRequestPacingConfig extends RequestPacingRule {
1315
1325
  models?: Record<string, RequestPacingRule>;
1316
1326
  }
1317
1327
 
1328
+ export interface FastWire {
1329
+ kind: "service-tier" | "anthropic-speed";
1330
+ /** Canonical tier name to upstream wire spelling. */
1331
+ canonicalToWire: Readonly<Record<string, string>>;
1332
+ /** Policy for non-canonical caller-provided tier values. */
1333
+ foreignCallerTiers: "verbatim" | "drop";
1334
+ /** Anthropic speed headers/betas reserved for the later wire implementation. */
1335
+ betas?: readonly string[];
1336
+ }
1337
+
1338
+ /** Durable per-attempt service-tier fact produced at the adapter serialization boundary. */
1339
+ export interface AttemptTierOutcome {
1340
+ canonical?: "priority";
1341
+ wireKind?: FastWire["kind"] | null;
1342
+ wireValue?: string | null;
1343
+ fastOutcome: "not-requested" | "applied" | "downgraded" | "unknown";
1344
+ fastDowngradeReason?: "route-unsupported" | "wire-unavailable" | "response-declined";
1345
+ callerTierDropped?: boolean;
1346
+ callerFastSuppressedByConfig?: boolean;
1347
+ confirmation: "confirmed" | "assumed" | "downgraded" | "unknown";
1348
+ responseServiceTier?: string;
1349
+ }
1350
+
1351
+ /**
1352
+ * Request-local observation inputs captured before the final tier action mutates the parsed view.
1353
+ * This is not persisted; the final adapter turns it into AttemptTierOutcome after serialization.
1354
+ */
1355
+ export interface TierObservationContext {
1356
+ capability: boolean | undefined;
1357
+ eligibility:
1358
+ | "eligible"
1359
+ | "capability-unsupported"
1360
+ | "unclassified"
1361
+ | "wire-unavailable"
1362
+ | "pin-unavailable";
1363
+ fastWire: FastWire | null;
1364
+ demandDecision: "force-fast" | "force-default" | "inherit";
1365
+ callerTier?: string;
1366
+ }
1367
+
1368
+ export type TierDecision =
1369
+ | { readonly kind: "forward-caller" }
1370
+ | { readonly kind: "drop" }
1371
+ | { readonly kind: "set"; readonly value: string };
1372
+
1318
1373
  /**
1319
1374
  * One configured provider entry. `authMode` (default `"key"`) decides whether same-target 429
1320
1375
  * retries are allowed; OAuth/forward credentials and local runtimes are never replayed.
@@ -1338,6 +1393,11 @@ export interface OcxProviderConfig {
1338
1393
  * as before.
1339
1394
  */
1340
1395
  modelAdapters?: Record<string, string>;
1396
+ /**
1397
+ * Fast-wire declaration. `null` explicitly disables adapter-derived defaults;
1398
+ * absence derives from the final model adapter.
1399
+ */
1400
+ fastWire?: FastWire | null;
1341
1401
  baseUrl: string;
1342
1402
  /**
1343
1403
  * Optional relative resource path for key-auth openai-responses requests. Must start with `/`
@@ -1365,14 +1425,14 @@ export interface OcxProviderConfig {
1365
1425
  */
1366
1426
  requiresAdjacentResponsesToolResults?: boolean;
1367
1427
  /**
1368
- * Provider fallback for the OpenAI `service_tier` parameter. On Responses routes this
1369
- * is the complete wire opt-in; Chat routes additionally require `chatServiceTier` or an
1370
- * exact-model true declaration.
1371
- * Tri-state: `true` lets fast mode inject/remove the field (an unset
1372
- * fast mode preserves a caller-supplied value); `false` strips the field and
1428
+ * Provider fallback for canonical Fast capability over an OpenAI `service_tier` wire.
1429
+ * This pure tri-state feeds catalog publication, routing eligibility, compatibility
1430
+ * fingerprints, and proxy-owned canonical Fast injection on both Responses and Chat routes.
1431
+ * Tri-state: `true` lets fast mode inject/remove the canonical field; `false` strips it and
1373
1432
  * never injects, because an upstream documented as not supporting the parameter
1374
- * must not receive it; absent (`undefined`) leaves the provider unclassified —
1375
- * caller-supplied values are preserved untouched, and fast mode never injects.
1433
+ * must not receive it; absent (`undefined`) leaves the provider unclassified — fast mode never
1434
+ * injects or translates, and caller values pass only under the final wire's forwarding permission.
1435
+ * On Chat, that CallerTierForward permission is `chatServiceTier`; Responses retains passthrough.
1376
1436
  * An explicit config value always wins over the registry default.
1377
1437
  */
1378
1438
  supportsServiceTier?: boolean;
@@ -1396,8 +1456,10 @@ export interface OcxProviderConfig {
1396
1456
  * HTTP/2 via TLS ALPN by default; some Cloudflare-fronted SSE endpoints hang on
1397
1457
  * HTTP/2 streaming responses (issue #1668). "http1.1" / "h1" forces HTTP/1.1,
1398
1458
  * "http2" / "h2" forces HTTP/2. Absent or "auto" keeps Bun's default negotiation
1399
- * (current behavior unchanged). Only meaningful for https: base URLs.
1400
- */
1459
+ * (current behavior unchanged). Explicit pins require an https: target and fail locally when the
1460
+ * pin cannot be honored. Cursor additionally maps an HTTP/1.1 pin onto its RunSSE + BidiAppend
1461
+ * compatibility transport.
1462
+ */
1401
1463
  upstreamHttpVersion?: UpstreamHttpVersion;
1402
1464
  /**
1403
1465
  * Google only. When `false`, the AI Studio (direct) path sends Gemini Flash ids
@@ -1589,13 +1651,16 @@ export interface OcxProviderConfig {
1589
1651
  */
1590
1652
  promptCacheKey?: boolean;
1591
1653
  /**
1592
- * Opt-in: forward `service_tier` to the upstream `/chat/completions` body.
1654
+ * Opt-in: forward caller `service_tier` values to the upstream `/chat/completions` body.
1655
+ * On a classified route it governs foreign values (for example `flex`), not proxy-owned
1656
+ * canonical Fast after capability validation. On an unclassified route it governs every caller
1657
+ * value, including canonical spellings, because no Fast capability has been validated.
1593
1658
  * OpenAI-specific extension with the same hazard as `promptCacheKey` — strict backends
1594
1659
  * reject unknown fields, and 66 registry providers share the `openai-chat` adapter, so a
1595
1660
  * caller-supplied `service_tier` would otherwise turn working requests into upstream 400s.
1596
- * Exact models may opt in through `modelSupportsServiceTier` instead; provider-level
1597
- * `supportsServiceTier: false` remains a global denial. Default off; only enable for
1598
- * providers that document this parameter on the chat wire.
1661
+ * Exact-model `true` enables canonical Fast capability but does not grant foreign-tier
1662
+ * forwarding; provider-level `supportsServiceTier: false` remains a global denial. Default off;
1663
+ * only enable for providers that document this parameter on the chat wire.
1599
1664
  */
1600
1665
  chatServiceTier?: boolean;
1601
1666
  /**
@@ -1750,6 +1815,19 @@ const ANTHROPIC_WIRE_MODELS: Record<string, ReadonlySet<string>> = {
1750
1815
  "opencode-go": new Set(["minimax-m2.5", "minimax-m2.7", "minimax-m3"]),
1751
1816
  };
1752
1817
 
1818
+ function anthropicWireModelsForProvider(providerName: string): ReadonlySet<string> | undefined {
1819
+ return Object.hasOwn(ANTHROPIC_WIRE_MODELS, providerName)
1820
+ ? ANTHROPIC_WIRE_MODELS[providerName]
1821
+ : undefined;
1822
+ }
1823
+
1824
+ /** Detached provider-local hard-pin table for pure wire-policy resolution. */
1825
+ export function captureWireAdapterHardPins(providerName: string): Readonly<Record<string, string>> {
1826
+ const models = anthropicWireModelsForProvider(providerName);
1827
+ if (!models) return Object.freeze({});
1828
+ return Object.freeze(Object.fromEntries([...models].map(modelId => [modelId, "anthropic"])));
1829
+ }
1830
+
1753
1831
  /**
1754
1832
  * True when the upstream speaks exactly one wire for this model, so a configured
1755
1833
  * override must not apply.
@@ -1759,7 +1837,7 @@ const ANTHROPIC_WIRE_MODELS: Record<string, ReadonlySet<string>> = {
1759
1837
  * adapter" would pass on the first pass and then let the override win on the second.
1760
1838
  */
1761
1839
  export function isWirePinnedModel(providerName: string, modelId: string): boolean {
1762
- return ANTHROPIC_WIRE_MODELS[providerName]?.has(modelId) ?? false;
1840
+ return anthropicWireModelsForProvider(providerName)?.has(modelId) ?? false;
1763
1841
  }
1764
1842
 
1765
1843
  /** The wire a pinned model must use, or undefined when the model is not pinned. */
@@ -1773,6 +1851,15 @@ export interface CodexAccount {
1773
1851
  /** User-owned display label; never participates in routing or identity checks. */
1774
1852
  alias?: string;
1775
1853
  plan?: string;
1854
+ /**
1855
+ * Provenance of `plan`. WHAM (live quota API) is authoritative; the JWT
1856
+ * `chatgpt_plan_type` claim is a fallback that may lag a plan change. A JWT write
1857
+ * must never overwrite a WHAM-sourced plan observed for the same credential
1858
+ * generation — only a newer generation (token refresh after the WHAM read) may.
1859
+ */
1860
+ planSource?: "jwt" | "wham";
1861
+ /** Credential generation at which `plan`/`planSource` was recorded. */
1862
+ planCredentialGeneration?: number;
1776
1863
  chatgptAccountId?: string;
1777
1864
  logLabel?: string;
1778
1865
  isMain: boolean;
package/src/update/job.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  } from "../config";
15
15
  import { isProcessAlive, killProxy } from "../lib/process-control";
16
16
  import { selfLaunchArgv } from "../lib/self-launch-argv";
17
+ import { killWindowsSchedulerWrappers } from "../lib/windows-service-wrappers";
17
18
  import {
18
19
  buildWindowsElevatedArgumentList,
19
20
  resolveTrustedWindowsPowerShellExe,
@@ -1371,26 +1372,16 @@ function stopWindowsServiceWrappersBestEffort(): void {
1371
1372
  * Best-effort termination of surviving Windows scheduler launcher/wrapper processes.
1372
1373
  * `schtasks /end` ends the task instance but often leaves wscript/cmd running the
1373
1374
  * `:loop` batch, which brings the proxy back during post-update reclaim.
1375
+ *
1376
+ * This used to match the bare filenames with -like '*name*', which could stop a
1377
+ * wrapper belonging to a DIFFERENT OpenCodex home under the same account. The
1378
+ * shared killer scopes to this home's canonical paths as complete tokens.
1374
1379
  */
1375
1380
  function killWindowsServiceWrapperProcesses(): void {
1376
- if (process.platform !== "win32") return;
1377
- try {
1378
- const ps = [
1379
- "$pats = @('opencodex-service.cmd','opencodex-service-launcher.vbs');",
1380
- "Get-CimInstance Win32_Process | Where-Object {",
1381
- " if ($_.ProcessId -eq $PID) { return $false };",
1382
- " $c = $_.CommandLine; if (-not $c) { return $false };",
1383
- " foreach ($p in $pats) { if ($c -like ('*' + $p + '*')) { return $true } };",
1384
- " $false",
1385
- "} | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }",
1386
- ].join(" ");
1387
- spawnSync(resolveTrustedWindowsPowerShellExe(), [
1388
- "-NoProfile", "-NoLogo", "-NonInteractive",
1389
- "-Command", ps,
1390
- ], { stdio: "ignore", timeout: 5000, windowsHide: true });
1391
- } catch {
1392
- /* best-effort */
1393
- }
1381
+ killWindowsSchedulerWrappers({
1382
+ scriptPath: join(getConfigDir(), "opencodex-service.cmd"),
1383
+ launcherPath: join(getConfigDir(), "opencodex-service-launcher.vbs"),
1384
+ });
1394
1385
  }
1395
1386
 
1396
1387
  /** Exposed for tests: drives the non-service restart path with injected io. */
@@ -0,0 +1,22 @@
1
+ export type InstallTreeVerification = { ok: boolean; failures: string[] };
2
+ export function verifyInstallTree(packageDir: string, expectedVersion?: string): InstallTreeVerification;
3
+ export function bootRestoreProbe(
4
+ packageDir: string,
5
+ deps?: { rename?: (from: string, to: string) => void },
6
+ ): { action: "none" | "reaped" | "restored" | "failed"; count?: number; from?: string; error?: string };
7
+ export function transactionalNpmUpdate(args: {
8
+ packageDir: string;
9
+ pkgName: string;
10
+ targetVersion?: string;
11
+ tag: string;
12
+ runNpm: (args: string[]) => { status: number | null };
13
+ log?: (line: string) => void;
14
+ deps?: { rename?: (from: string, to: string) => void };
15
+ }): {
16
+ ok: boolean;
17
+ phase: "stage" | "verify" | "swap-backup" | "swap-live" | "post-verify" | "double-fault" | "done";
18
+ error?: string;
19
+ rolledBack?: boolean;
20
+ backup?: string;
21
+ };
22
+
@@ -0,0 +1,259 @@
1
+ /**
2
+ * Transactional npm self-update: stage -> verify -> swap -> rollback (#1942 / #1849).
3
+ *
4
+ * The legacy path ran `npm install -g` straight into the live global tree, so a
5
+ * failure after npm removed the old files left a file-less package skeleton with no
6
+ * recovery. This module stages the new version into a SIBLING directory of the live
7
+ * package (same volume, so directory renames are atomic-ish and never cross devices),
8
+ * verifies the staged tree with a manifest before anything live is touched, then swaps
9
+ * live -> backup -> stage-into-live with a reverse-rename rollback on failure and a
10
+ * recovery marker on double fault.
11
+ *
12
+ * Layout (siblings, never children — a child would travel WITH the live rename and the
13
+ * live dir cannot move into its own subtree):
14
+ * <scopeDir>/opencodex live package
15
+ * <scopeDir>/.ocx-staging-<ts>/ npm --prefix root (contains node_modules/...)
16
+ * <scopeDir>/.ocx-backup-<ts>/opencodex previous live tree during/after the swap
17
+ * <scopeDir>/.ocx-recovery.json double-fault marker with a one-line restore
18
+ */
19
+ import { spawnSync } from "node:child_process";
20
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
21
+ import { dirname, join } from "node:path";
22
+
23
+ /** Verification manifest for a staged (or live) package tree. */
24
+ export function verifyInstallTree(packageDir, expectedVersion) {
25
+ const failures = [];
26
+ let pkg;
27
+ try {
28
+ pkg = JSON.parse(readFileSync(join(packageDir, "package.json"), "utf8"));
29
+ } catch (error) {
30
+ return { ok: false, failures: ["package.json unreadable: " + (error?.message ?? String(error))] };
31
+ }
32
+ if (expectedVersion && pkg.version !== expectedVersion) {
33
+ failures.push("package.json version " + pkg.version + " != expected " + expectedVersion);
34
+ }
35
+ const launcher = join(packageDir, "bin", "ocx.mjs");
36
+ try {
37
+ const st = statSync(launcher);
38
+ if (!st.isFile() || st.size < 1024) failures.push("bin/ocx.mjs missing or truncated");
39
+ } catch {
40
+ failures.push("bin/ocx.mjs absent");
41
+ }
42
+ // The bundled Bun binary is the load-bearing artifact: without it the launcher exits
43
+ // before serving anything, and a boot probe that called this tree healthy would reap
44
+ // the only backup (review High 3). Size-gate the real binary, not just its package.json.
45
+ const bunPkgDir = join(packageDir, "node_modules", "bun");
46
+ if (existsSync(bunPkgDir)) {
47
+ const bunBinary = findLargestFile(bunPkgDir);
48
+ if (!bunBinary || bunBinary.size < 10 * 1024 * 1024) {
49
+ failures.push("bundled Bun binary missing or truncated (< 10MB)");
50
+ }
51
+ }
52
+ // Sentinel direct deps: each must have an intact package.json. The bundled Bun dep is
53
+ // the load-bearing one — without it the launcher cannot start the proxy at all.
54
+ const deps = Object.keys(pkg.dependencies ?? {});
55
+ const sentinels = deps.filter(name => name === "bun" || name === "zod").length > 0
56
+ ? deps.filter(name => name === "bun" || name === "zod")
57
+ : deps.slice(0, 2);
58
+ for (const name of sentinels) {
59
+ const depPkg = join(packageDir, "node_modules", ...name.split("/"), "package.json");
60
+ if (!existsSync(depPkg)) failures.push("sentinel dependency missing: " + name);
61
+ }
62
+ return failures.length === 0 ? { ok: true, failures: [] } : { ok: false, failures };
63
+ }
64
+
65
+ function stampedName(prefix) {
66
+ return prefix + "-" + new Date().toISOString().replace(/[:.]/g, "-");
67
+ }
68
+
69
+ /** Largest regular file under a directory tree (bounded depth) — locates the Bun binary. */
70
+ function findLargestFile(root, depth = 3) {
71
+ let best;
72
+ let names = [];
73
+ try { names = readdirSync(root); } catch { return undefined; }
74
+ for (const name of names) {
75
+ const full = join(root, name);
76
+ let st;
77
+ try { st = statSync(full); } catch { continue; }
78
+ if (st.isFile()) {
79
+ if (!best || st.size > best.size) best = { path: full, size: st.size };
80
+ } else if (st.isDirectory() && depth > 0) {
81
+ const sub = findLargestFile(full, depth - 1);
82
+ if (sub && (!best || sub.size > best.size)) best = sub;
83
+ }
84
+ }
85
+ return best;
86
+ }
87
+
88
+ /** Bounded Windows-class rename retry: EPERM/EBUSY/EACCES from AV/indexers clears in ms. */
89
+ function renameWithRetry(rename, from, to, attempts = 5) {
90
+ for (let attempt = 0; ; attempt += 1) {
91
+ try {
92
+ rename(from, to);
93
+ return;
94
+ } catch (error) {
95
+ const code = error?.code;
96
+ const retryable = code === "EPERM" || code === "EBUSY" || code === "EACCES";
97
+ if (!retryable || attempt >= attempts - 1) throw error;
98
+ // Synchronous bounded backoff (launcher context has no async loop here).
99
+ const until = Date.now() + 100 * (attempt + 1);
100
+ while (Date.now() < until) { /* spin briefly; total worst case ~1.5s */ }
101
+ }
102
+ }
103
+ }
104
+
105
+ function recoveryMarkerPath(scopeDir) {
106
+ return join(scopeDir, ".ocx-recovery.json");
107
+ }
108
+
109
+ /** Startup probe: restore a backup when the live tree is broken (D4 power-loss rows). */
110
+ export function bootRestoreProbe(packageDir, deps = {}) {
111
+ const rename = deps.rename ?? renameSync;
112
+ const scopeDir = dirname(packageDir);
113
+ let backups = [];
114
+ try {
115
+ backups = readdirSync(scopeDir).filter(name => name.startsWith(".ocx-backup-")).sort();
116
+ } catch {
117
+ return { action: "none" };
118
+ }
119
+ if (backups.length === 0) return { action: "none" };
120
+ const liveOk = existsSync(join(packageDir, "package.json"))
121
+ && verifyInstallTree(packageDir).ok;
122
+ const newestBackup = join(scopeDir, backups[backups.length - 1], "opencodex");
123
+ if (liveOk) {
124
+ // Live is healthy: the backups are leftovers from a completed swap. Reap them.
125
+ for (const name of backups) {
126
+ try { rmSync(join(scopeDir, name), { recursive: true, force: true }); } catch { /* keep */ }
127
+ }
128
+ try { rmSync(recoveryMarkerPath(scopeDir), { force: true }); } catch { /* keep */ }
129
+ return { action: "reaped", count: backups.length };
130
+ }
131
+ if (!existsSync(join(newestBackup, "package.json"))) return { action: "none" };
132
+ try {
133
+ try { rmSync(packageDir, { recursive: true, force: true }); } catch { /* may not exist */ }
134
+ rename(newestBackup, packageDir);
135
+ return { action: "restored", from: newestBackup };
136
+ } catch (error) {
137
+ return { action: "failed", error: error?.message ?? String(error) };
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Run the transactional update. `runNpm(args, opts)` is injected so the caller keeps
143
+ * its hardened npm resolution (npm-invocation.mjs) and logging.
144
+ */
145
+ export function transactionalNpmUpdate({
146
+ packageDir,
147
+ pkgName,
148
+ targetVersion,
149
+ tag,
150
+ runNpm,
151
+ log = () => {},
152
+ deps = {},
153
+ }) {
154
+ const rename = deps.rename ?? renameSync;
155
+ const scopeDir = dirname(packageDir);
156
+ const stageRoot = join(scopeDir, stampedName(".ocx-staging"));
157
+ // GLOBAL-style staging (-g --prefix): npm nests the package's dependencies INSIDE the
158
+ // package dir, exactly like the live global tree this stage will replace. A local-style
159
+ // install would hoist bun/zod to stageRoot/node_modules — siblings that the swap would
160
+ // leave behind, shipping a dependency-less live tree (release-audit blocker).
161
+ // Layout: <stageRoot>/lib/node_modules/<pkg> on POSIX, <stageRoot>/node_modules/<pkg>
162
+ // on Windows.
163
+ const stagedCandidates = [
164
+ join(stageRoot, "lib", "node_modules", ...pkgName.split("/")),
165
+ join(stageRoot, "node_modules", ...pkgName.split("/")),
166
+ ];
167
+
168
+ // D1: stage to the side. --prefix keeps npm entirely inside stageRoot; the live tree
169
+ // and the npm bin shims are untouched until the swap. A failure HERE (mkdir EACCES,
170
+ // ENOSPC) must NOT fall back to the destructive legacy install (review High 4): the
171
+ // caller sees a normal phase failure with live untouched.
172
+ try {
173
+ mkdirSync(stageRoot, { recursive: true });
174
+ } catch (error) {
175
+ return { ok: false, phase: "stage", error: "could not create staging directory: " + (error?.message ?? String(error)) };
176
+ }
177
+ const spec = pkgName + "@" + (targetVersion || tag);
178
+ log("Staging " + spec + " into " + stageRoot);
179
+ const install = runNpm(["install", "-g", "--prefix", stageRoot, "--no-audit", "--no-fund", spec]);
180
+ if (install.status !== 0) {
181
+ try { rmSync(stageRoot, { recursive: true, force: true }); } catch { /* best effort */ }
182
+ return { ok: false, phase: "stage", error: "npm staging install failed (" + (install.status ?? "?") + ")" };
183
+ }
184
+ const stagedPackage = stagedCandidates.find(dir => existsSync(join(dir, "package.json")));
185
+ if (!stagedPackage) {
186
+ try { rmSync(stageRoot, { recursive: true, force: true }); } catch { /* best effort */ }
187
+ return { ok: false, phase: "verify", error: "staged package directory not found under " + stageRoot };
188
+ }
189
+
190
+ // D2: verify INSIDE the stage. Live is still untouched on any failure here.
191
+ const staged = verifyInstallTree(stagedPackage, targetVersion || undefined);
192
+ if (!staged.ok) {
193
+ try { rmSync(stageRoot, { recursive: true, force: true }); } catch { /* best effort */ }
194
+ return { ok: false, phase: "verify", error: "staged tree failed verification: " + staged.failures.join("; ") };
195
+ }
196
+
197
+ // D3: swap. live -> backup, stage -> live, re-verify live, rollback on failure.
198
+ const backupRoot = join(scopeDir, stampedName(".ocx-backup"));
199
+ const backupPackage = join(backupRoot, "opencodex");
200
+ try {
201
+ mkdirSync(backupRoot, { recursive: true });
202
+ } catch (error) {
203
+ try { rmSync(stageRoot, { recursive: true, force: true }); } catch { /* best effort */ }
204
+ return { ok: false, phase: "swap-backup", error: "could not create backup directory: " + (error?.message ?? String(error)) };
205
+ }
206
+ try {
207
+ renameWithRetry(rename, packageDir, backupPackage);
208
+ } catch (error) {
209
+ try { rmSync(stageRoot, { recursive: true, force: true }); } catch { /* best effort */ }
210
+ try { rmSync(backupRoot, { recursive: true, force: true }); } catch { /* best effort */ }
211
+ return { ok: false, phase: "swap-backup", error: "could not move live tree aside: " + (error?.message ?? String(error)) };
212
+ }
213
+ try {
214
+ renameWithRetry(rename, stagedPackage, packageDir);
215
+ } catch (error) {
216
+ // Rollback: reverse the first rename. Double fault leaves the recovery marker.
217
+ try {
218
+ renameWithRetry(rename, backupPackage, packageDir);
219
+ try { rmSync(stageRoot, { recursive: true, force: true }); } catch { /* best effort */ }
220
+ try { rmSync(backupRoot, { recursive: true, force: true }); } catch { /* best effort */ }
221
+ return { ok: false, phase: "swap-live", rolledBack: true, error: "could not place staged tree: " + (error?.message ?? String(error)) };
222
+ } catch (rollbackError) {
223
+ writeFileSync(recoveryMarkerPath(scopeDir), JSON.stringify({
224
+ at: new Date().toISOString(),
225
+ backup: backupPackage,
226
+ live: packageDir,
227
+ restore: 'move "' + backupPackage + '" back to "' + packageDir + '"',
228
+ error: String(error?.message ?? error),
229
+ rollbackError: String(rollbackError?.message ?? rollbackError),
230
+ }, null, 2));
231
+ return { ok: false, phase: "double-fault", rolledBack: false, error: "swap and rollback both failed; recovery marker written at " + recoveryMarkerPath(scopeDir) };
232
+ }
233
+ }
234
+ const liveCheck = verifyInstallTree(packageDir, targetVersion || undefined);
235
+ if (!liveCheck.ok) {
236
+ try {
237
+ rmSync(packageDir, { recursive: true, force: true });
238
+ rename(backupPackage, packageDir);
239
+ try { rmSync(stageRoot, { recursive: true, force: true }); } catch { /* best effort */ }
240
+ try { rmSync(backupRoot, { recursive: true, force: true }); } catch { /* best effort */ }
241
+ return { ok: false, phase: "post-verify", rolledBack: true, error: "live tree failed post-swap verification: " + liveCheck.failures.join("; ") };
242
+ } catch (rollbackError) {
243
+ writeFileSync(recoveryMarkerPath(scopeDir), JSON.stringify({
244
+ at: new Date().toISOString(),
245
+ backup: backupPackage,
246
+ live: packageDir,
247
+ restore: 'move "' + backupPackage + '" back to "' + packageDir + '"',
248
+ error: "post-swap verification failed: " + liveCheck.failures.join("; "),
249
+ rollbackError: String(rollbackError?.message ?? rollbackError),
250
+ }, null, 2));
251
+ return { ok: false, phase: "double-fault", rolledBack: false, error: "post-verify rollback failed; recovery marker written" };
252
+ }
253
+ }
254
+ // Success: stage scaffolding is disposable now; the backup stays until the next
255
+ // healthy boot reaps it (bootRestoreProbe) — the process that spawned this update may
256
+ // still hold the old cwd.
257
+ try { rmSync(stageRoot, { recursive: true, force: true }); } catch { /* best effort */ }
258
+ return { ok: true, phase: "done", backup: backupPackage };
259
+ }
package/src/usage/cost.ts CHANGED
@@ -14,7 +14,7 @@ import {
14
14
  getModelMetadata,
15
15
  resolveMetadataProvider,
16
16
  } from "../generated/model-metadata";
17
- import type { OcxUsage } from "../types";
17
+ import type { AttemptTierOutcome, OcxUsage } from "../types";
18
18
  import { baseProviderLabel, canonicalUsageProviderLabel } from "../providers/label";
19
19
  import type { PersistedUsageAttempt, UsageStatus } from "./log";
20
20
  import { canonicalAntigravityUsageModel } from "../providers/antigravity-models";
@@ -44,6 +44,7 @@ export interface ServiceTierContext {
44
44
  responseServiceTier?: string;
45
45
  requestedServiceTier?: string;
46
46
  configuredServiceTier?: string;
47
+ tierOutcome?: AttemptTierOutcome;
47
48
  }
48
49
 
49
50
  export interface CostTokens {
@@ -349,6 +350,7 @@ export type ServiceTierInput = string | ServiceTierContext;
349
350
  * and long-context exclusivity depends on that distinction.
350
351
  */
351
352
  export function serviceTierContext(entry: ServiceTierContext): ServiceTierContext {
353
+ if (entry.tierOutcome) return serviceTierContextFromOutcome(entry.tierOutcome);
352
354
  return {
353
355
  responseServiceTier: entry.responseServiceTier,
354
356
  requestedServiceTier: entry.requestedServiceTier,
@@ -356,6 +358,30 @@ export function serviceTierContext(entry: ServiceTierContext): ServiceTierContex
356
358
  };
357
359
  }
358
360
 
361
+ /** Convert one adapter-observed attempt outcome into the existing pricing provenance shape. */
362
+ export function serviceTierContextFromOutcome(outcome: AttemptTierOutcome): ServiceTierContext {
363
+ if (outcome.canonical === "priority" && outcome.confirmation === "confirmed") {
364
+ return { responseServiceTier: "priority" };
365
+ }
366
+ if (outcome.responseServiceTier !== undefined) {
367
+ return { responseServiceTier: outcome.responseServiceTier };
368
+ }
369
+ if (outcome.canonical === "priority" && outcome.confirmation === "assumed") {
370
+ return { requestedServiceTier: "priority" };
371
+ }
372
+ // An unclassified route makes no canonical Fast claim, but its adapter can still prove that
373
+ // it serialized a caller tier. Preserve that wire evidence instead of discarding the legacy
374
+ // top-level pricing signal merely because B0 added an outcome row.
375
+ if (
376
+ outcome.fastOutcome === "unknown"
377
+ && outcome.wireKind === "service-tier"
378
+ && typeof outcome.wireValue === "string"
379
+ ) {
380
+ return { requestedServiceTier: outcome.wireValue };
381
+ }
382
+ return {};
383
+ }
384
+
359
385
  function tierScalar(tier?: ServiceTierInput): string | undefined {
360
386
  return typeof tier === "string" ? tier : tier && effectiveServiceTier(tier);
361
387
  }
@@ -428,7 +454,7 @@ function applyPriorityMultiplier(
428
454
  * missing so combos can fail closed.
429
455
  */
430
456
  export function estimateAttemptCost(
431
- attempt: Pick<PersistedUsageAttempt, "ordinal" | "provider" | "model" | "usage" | "usageStatus">,
457
+ attempt: Pick<PersistedUsageAttempt, "ordinal" | "provider" | "model" | "usage" | "usageStatus" | "tierOutcome">,
432
458
  overlays: readonly ExpectedPriceOverlay[] = EXPECTED_PRICE_OVERLAYS,
433
459
  serviceTier?: ServiceTierInput,
434
460
  userOverlays: readonly ExpectedPriceOverlay[] = activeUserCostOverlays(),
@@ -438,15 +464,18 @@ export function estimateAttemptCost(
438
464
  if (!tokens) return null;
439
465
  const price = resolveMatchedPrice(attempt.provider, attempt.model, overlays, userOverlays);
440
466
  if (!price) return null;
467
+ const attemptServiceTier = attempt.tierOutcome
468
+ ? serviceTierContextFromOutcome(attempt.tierOutcome)
469
+ : serviceTier;
441
470
  const [tieredCost4, contextTier] = applyContextTier(
442
- price.cost4, attempt.provider, attempt.model, attempt.usage.inputTokens, serviceTier,
471
+ price.cost4, attempt.provider, attempt.model, attempt.usage.inputTokens, attemptServiceTier,
443
472
  );
444
473
  // Exclusive both ways: if the long rate applied, the request was NOT served as
445
474
  // Fast (Fast does not support long context), so the Fast multiplier must not
446
475
  // also apply — otherwise a downgraded request bills at both rates.
447
476
  const [effectiveCost4, multiplier] = contextTier
448
477
  ? [tieredCost4, 1] as const
449
- : applyPriorityMultiplier(tieredCost4, attempt.provider, attempt.model, serviceTier);
478
+ : applyPriorityMultiplier(tieredCost4, attempt.provider, attempt.model, attemptServiceTier);
450
479
  return {
451
480
  ordinal: attempt.ordinal,
452
481
  provider: attempt.provider,
@@ -465,7 +494,7 @@ export function estimateAttemptCost(
465
494
  * attempt is unpriced or unnormalizable, return null rather than a partial sum.
466
495
  */
467
496
  export function estimateComboCost(
468
- attempts: readonly Pick<PersistedUsageAttempt, "ordinal" | "provider" | "model" | "usage" | "usageStatus">[],
497
+ attempts: readonly Pick<PersistedUsageAttempt, "ordinal" | "provider" | "model" | "usage" | "usageStatus" | "tierOutcome">[],
469
498
  overlays: readonly ExpectedPriceOverlay[] = EXPECTED_PRICE_OVERLAYS,
470
499
  serviceTier?: ServiceTierInput,
471
500
  userOverlays: readonly ExpectedPriceOverlay[] = activeUserCostOverlays(),