@yansigit/opencodex 2.36.1-dev.20260829.48 → 2.36.1

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 (94) hide show
  1. package/gui/dist/assets/{ApiKeys-LOOFiZfv.js → ApiKeys-CsmNyf4I.js} +1 -1
  2. package/gui/dist/assets/{Claude-BwKzpXe3.js → Claude-B4sHsGTD.js} +1 -1
  3. package/gui/dist/assets/{CodexSet-B5qi9KeE.js → CodexSet-BuAQ4YQn.js} +1 -1
  4. package/gui/dist/assets/{FileIntegrationPage-B0sUef6W.js → FileIntegrationPage-BC65sEaS.js} +1 -1
  5. package/gui/dist/assets/{Grok-bsb4n-f4.js → Grok-DNJWlISf.js} +1 -1
  6. package/gui/dist/assets/{Integrations-CSMwFDvM.js → Integrations-vGJshFZK.js} +2 -2
  7. package/gui/dist/assets/{IntegrationsOverview-CTORdKJA.js → IntegrationsOverview-DYMkDFHE.js} +1 -1
  8. package/gui/dist/assets/{Logs-BWYbfELf.js → Logs-Coc7_RPP.js} +1 -1
  9. package/gui/dist/assets/{Models-CexEtdT1.js → Models-CKVT8Meb.js} +1 -1
  10. package/gui/dist/assets/{NumberStepper-BapSFQnW.js → NumberStepper-Dug4wR8n.js} +1 -1
  11. package/gui/dist/assets/{Providers-BwUFeAgA.js → Providers-DyaCHRWL.js} +1 -1
  12. package/gui/dist/assets/{RestoreDialog-CD8piq90.js → RestoreDialog-BpuvVU2w.js} +1 -1
  13. package/gui/dist/assets/{Startup-BgX731C2.js → Startup-DAUQqNEN.js} +1 -1
  14. package/gui/dist/assets/{Storage-Bgq7HphP.js → Storage-DfP95SlJ.js} +1 -1
  15. package/gui/dist/assets/{Subagents-ChxItbeF.js → Subagents-CMNsZLfk.js} +1 -1
  16. package/gui/dist/assets/{Usage-BkylzP50.js → Usage-ESFRfBMO.js} +1 -1
  17. package/gui/dist/assets/{codex-stale-banner-D4gvSWnO.js → codex-stale-banner-W4FhikN7.js} +1 -1
  18. package/gui/dist/assets/{data-surface-BtO3lwam.js → data-surface-B8PX8rjm.js} +1 -1
  19. package/gui/dist/assets/{data-surface-pCXChiBf.js → data-surface-CETFXqiA.js} +1 -1
  20. package/gui/dist/assets/{index-CNopOid3.js → index-DHQHBRhb.js} +3 -3
  21. package/gui/dist/assets/{model-display-CiUpg8T9.js → model-display-OBTJhQwT.js} +1 -1
  22. package/gui/dist/assets/{provider-payload-b2jlS-On.js → provider-payload-CJRJUn7w.js} +1 -1
  23. package/gui/dist/assets/{section-tabs-BP7gEPK6.js → section-tabs-B8sLMiz6.js} +1 -1
  24. package/gui/dist/assets/shared-DI18-uF7.js +69 -0
  25. package/gui/dist/index.html +2 -2
  26. package/package.json +3 -1
  27. package/src/adapters/base.ts +26 -0
  28. package/src/adapters/cursor/catalog.ts +541 -0
  29. package/src/adapters/cursor/cursor-errors.ts +15 -0
  30. package/src/adapters/cursor/discovery.ts +34 -41
  31. package/src/adapters/cursor/envelope-echo.ts +128 -0
  32. package/src/adapters/cursor/request-builder.ts +19 -12
  33. package/src/adapters/cursor/tool-definitions.ts +2 -1
  34. package/src/adapters/cursor/tool-result-normalize.ts +23 -31
  35. package/src/adapters/cursor.ts +21 -2
  36. package/src/adapters/exec-tool-result-normalize.ts +99 -0
  37. package/src/adapters/google-antigravity-replay.ts +71 -2
  38. package/src/adapters/google.ts +19 -4
  39. package/src/adapters/kiro-constants.ts +12 -0
  40. package/src/adapters/kiro.ts +128 -11
  41. package/src/adapters/openai-chat.ts +16 -2
  42. package/src/adapters/openai-responses.ts +15 -2
  43. package/src/adapters/tool-catalog-nudge.ts +2 -1
  44. package/src/adapters/xai-web-search.ts +10 -14
  45. package/src/claude/outbound.ts +14 -3
  46. package/src/cli/access.ts +46 -3
  47. package/src/cli/account-api.ts +93 -16
  48. package/src/cli/account-extended.ts +262 -36
  49. package/src/cli/account-main.ts +12 -12
  50. package/src/cli/account.ts +40 -10
  51. package/src/cli/agent.ts +8 -1
  52. package/src/cli/capabilities-command.ts +94 -0
  53. package/src/cli/capabilities.ts +535 -0
  54. package/src/cli/claude-desktop.ts +31 -11
  55. package/src/cli/dispatch.ts +195 -27
  56. package/src/cli/doctor.ts +100 -1
  57. package/src/cli/help.ts +11 -2
  58. package/src/cli/index.ts +19 -3
  59. package/src/cli/inspect.ts +230 -0
  60. package/src/cli/observe.ts +11 -3
  61. package/src/cli/registry.ts +34 -2
  62. package/src/cli/runtime-api.ts +51 -7
  63. package/src/cli/status.ts +16 -0
  64. package/src/cli/storage.ts +234 -0
  65. package/src/cli/system-command.ts +16 -0
  66. package/src/cli/usage-report.ts +52 -2
  67. package/src/cli/version-skew.ts +46 -0
  68. package/src/codex/account-label.ts +21 -0
  69. package/src/codex/catalog/provider-fetch.ts +4 -0
  70. package/src/codex/transition-state.ts +12 -3
  71. package/src/compatibility/openai-responses.ts +9 -1
  72. package/src/generated/compatibility-version.json +94 -58
  73. package/src/integrations/ownership-policy.ts +24 -5
  74. package/src/integrations/ownership.ts +36 -2
  75. package/src/integrations/state.ts +40 -7
  76. package/src/integrations/writer.ts +21 -3
  77. package/src/lib/admin-secrets.ts +24 -0
  78. package/src/lib/errors.ts +25 -1
  79. package/src/lib/service-secrets.ts +15 -0
  80. package/src/oauth/store.ts +16 -6
  81. package/src/providers/label.ts +34 -1
  82. package/src/responses/turn-termination.ts +107 -0
  83. package/src/server/management/logs-usage-routes.ts +5 -27
  84. package/src/server/management/route-registry.ts +317 -0
  85. package/src/server/proxy-liveness.ts +27 -4
  86. package/src/server/request-log.ts +37 -17
  87. package/src/server/responses/core.ts +80 -4
  88. package/src/server/responses/policy-fallback.ts +1 -1
  89. package/src/service.ts +34 -0
  90. package/src/storage/policy-job.ts +14 -4
  91. package/src/storage/policy.ts +79 -33
  92. package/src/usage/log.ts +32 -6
  93. package/src/usage/summary.ts +22 -34
  94. package/gui/dist/assets/shared-BdYGV-yJ.js +0 -69
@@ -43,6 +43,10 @@ import {
43
43
  previousResponseScopeMismatch,
44
44
  rememberResponseState,
45
45
  } from "../../responses/state";
46
+ import {
47
+ bindTurnTerminationScope,
48
+ rememberDeliveredFinalAnswer,
49
+ } from "../../responses/turn-termination";
46
50
  import {
47
51
  isValidProviderContinuationOwner,
48
52
  mergeProviderContinuationPayload,
@@ -122,6 +126,7 @@ import {
122
126
  resolveAnthropicAccountForSession,
123
127
  rotateAnthropicAccountOn429,
124
128
  } from "../../oauth/anthropic-routing";
129
+ import { stampOAuthAccountLabel } from "../../providers/label";
125
130
  import {
126
131
  antigravitySessionKeyFromParts,
127
132
  bindAntigravitySessionAffinity,
@@ -2508,6 +2513,10 @@ async function handleResponsesInner(
2508
2513
  threadIdHeader: req.headers.get("thread-id"),
2509
2514
  cursorConversationId: parsed._cursorConversationId,
2510
2515
  });
2516
+ bindTurnTerminationScope(parsed, resolvedConversationId);
2517
+ const rememberKiroDeliveredFinalAnswer = (adapterName: string, response: unknown): void => {
2518
+ if (adapterName === "kiro") rememberDeliveredFinalAnswer(parsed, response);
2519
+ };
2511
2520
  // _clientThreadId remains the routing/continuation identity supplied by Codex. Replay state uses
2512
2521
  // a dedicated raw conversation namespace so mixed headers that carry the same identity still
2513
2522
  // match, and a shared/synthetic session_id cannot coalesce distinct thread/Cursor conversations.
@@ -3040,9 +3049,12 @@ async function handleResponsesInner(
3040
3049
  accountId: snapshot.accountId,
3041
3050
  generation: snapshot.generation,
3042
3051
  };
3043
- logCtx.accountLogLabel = snapshot.accountId ?? genericFailoverAccountId;
3044
3052
  bindAntigravitySessionAffinity(antigravitySessionKey, snapshot.accountId ?? genericFailoverAccountId);
3045
3053
  }
3054
+ // Re-stamp: a request that rotated accounts must be attributed to the account that actually
3055
+ // served it. All three rotation sites funnel through here, so this is the only re-stamp
3056
+ // needed -- and putting it anywhere else would let one of the three drift.
3057
+ stampOAuthAccountLabel(logCtx, route.providerName, route.provider, snapshot.accountId);
3046
3058
  return true;
3047
3059
  };
3048
3060
  const oauthSessionKeyParts = {
@@ -3151,9 +3163,10 @@ async function handleResponsesInner(
3151
3163
  };
3152
3164
  if (isOAuth401ReplayProvider) sentOAuthSnapshot = resolved;
3153
3165
  route.provider = { ...route.provider, apiKey: resolved.accessToken };
3154
- if (resolved.accountId) {
3155
- logCtx.accountLogLabel = resolved.accountId;
3156
- }
3166
+ // Attribution is independent of failover (#2699): stamped from the resolved snapshot
3167
+ // itself, not from inside the `isGenericFailoverProvider` branch below, so a future
3168
+ // narrowing of that predicate cannot silently switch attribution off.
3169
+ stampOAuthAccountLabel(logCtx, route.providerName, route.provider, resolved.accountId);
3157
3170
  // Remember which account actually served this request so a 429 cools THAT one, not
3158
3171
  // whichever account is active by the time the response comes back (#2568).
3159
3172
  if (isGenericFailoverProvider(route.providerName, route.provider)) {
@@ -4810,6 +4823,7 @@ async function handleResponsesInner(
4810
4823
  ...(options.forceEmptyResponseId ? { forceEmptyResponseId: true } : {}),
4811
4824
  onCompletedResponse: (response, providerState) => {
4812
4825
  commitReasoningReplayServingRoute();
4826
+ rememberKiroDeliveredFinalAnswer(adapter.name, response);
4813
4827
  rememberResponseState(
4814
4828
  parsed._rawBody,
4815
4829
  response,
@@ -5135,6 +5149,7 @@ async function handleResponsesInner(
5135
5149
  },
5136
5150
  onCompletedResponse: (response: Record<string, unknown>, providerState?: OcxProviderContinuationState) => {
5137
5151
  commitReasoningReplayServingRoute();
5152
+ rememberKiroDeliveredFinalAnswer(adapter.name, response);
5138
5153
  if (!routedCompaction) {
5139
5154
  rememberResponseState(
5140
5155
  parsed._rawBody,
@@ -5204,6 +5219,7 @@ async function handleResponsesInner(
5204
5219
  },
5205
5220
  });
5206
5221
  if (!routedCompaction) {
5222
+ rememberKiroDeliveredFinalAnswer(adapter.name, json);
5207
5223
  rememberResponseState(
5208
5224
  parsed._rawBody,
5209
5225
  json,
@@ -5238,6 +5254,64 @@ async function handleResponsesInner(
5238
5254
  // reuse is safe, and releaseBodyObservation is idempotent per build.
5239
5255
  let initialRequest: AdapterRequest | undefined;
5240
5256
  let inputTokenEstimate: number | undefined;
5257
+ // An adapter may know the turn needs no inference at all — Kiro's replayed history ending in a
5258
+ // delivered final answer. Answer it locally: no build (so no token estimate), no send (so
5259
+ // sendCount stays 0), and crucially no empty-completion guard, which treats an outputless
5260
+ // terminal as a failed turn and re-invokes the identical request. Routing this through the
5261
+ // ordinary event path would therefore reinstate the loop it exists to end.
5262
+ const localTerminal = activeAdapter.localTerminal?.(parsed);
5263
+ if (localTerminal) {
5264
+ logCtx.localTerminalReason = localTerminal.reason;
5265
+ // Mark the physical attempt too, not just the parent row. `finishRequestAttempt` finalizes the
5266
+ // attempt through the same estimated-provider path, so without this the row reads exact while
5267
+ // its own attempt still claims an estimate — the detailed accounting a maintainer actually
5268
+ // reads for a zero-send turn.
5269
+ if (logCtx.activeAttempt) logCtx.activeAttempt.locallyAnswered = true;
5270
+ cleanupUpstreamAbort();
5271
+ upstream.abort();
5272
+ const terminalEvents: AdapterEvent[] = [{
5273
+ type: "done",
5274
+ endTurn: true,
5275
+ usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
5276
+ }];
5277
+ if (parsed.stream) {
5278
+ const localSse = bridgeToResponsesSSE(
5279
+ (async function* () { yield* terminalEvents; })(),
5280
+ parsed._responseModelId ?? parsed.modelId,
5281
+ toolBridgeMaps.toolNsMap,
5282
+ toolBridgeMaps.freeformToolNames,
5283
+ toolBridgeMaps.toolSearchToolNames,
5284
+ undefined,
5285
+ 2_000,
5286
+ {
5287
+ translatorBudget,
5288
+ ...(options.forceEmptyResponseId ? { responseId: "" } : {}),
5289
+ ...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}),
5290
+ },
5291
+ );
5292
+ // Same lifetime tracking as every other streaming return in this function: the turn
5293
+ // admission lease is released when the body finishes or the client disconnects. Returning
5294
+ // the raw stream would hold a lease for a turn that already has all of its output.
5295
+ const localTurnAc = new AbortController();
5296
+ return new Response(
5297
+ trackStreamLifetime(localSse, localTurnAc, undefined, options.turnAdmissionLease),
5298
+ {
5299
+ headers: {
5300
+ "Content-Type": "text/event-stream",
5301
+ "Cache-Control": "no-cache",
5302
+ "Connection": "keep-alive",
5303
+ "X-Accel-Buffering": "no",
5304
+ },
5305
+ },
5306
+ );
5307
+ }
5308
+ return new Response(
5309
+ JSON.stringify(buildResponseJSON(terminalEvents, parsed._responseModelId ?? parsed.modelId, {
5310
+ translatorBudget,
5311
+ })),
5312
+ { headers: { "Content-Type": "application/json" } },
5313
+ );
5314
+ }
5241
5315
  try {
5242
5316
  assertGoogleOptionsRoute(activeAdapter, route.provider);
5243
5317
  initialRequest = await activeAdapter.buildRequest(parsed, {
@@ -6434,6 +6508,7 @@ async function handleResponsesInner(
6434
6508
  },
6435
6509
  onCompletedResponse: (response: Record<string, unknown>, providerState?: OcxProviderContinuationState) => {
6436
6510
  commitReasoningReplayServingRoute();
6511
+ rememberKiroDeliveredFinalAnswer(activeAdapter.name, response);
6437
6512
  // Compaction turns must NOT enter the continuation cache: _rawBody still holds the full
6438
6513
  // PRE-compaction history, and a later previous_response_id expansion would rehydrate the
6439
6514
  // giant stale chain Codex just replaced.
@@ -6511,6 +6586,7 @@ async function handleResponsesInner(
6511
6586
  });
6512
6587
  // See the streaming branch: compaction turns skip the continuation cache.
6513
6588
  if (!routedCompaction) {
6589
+ rememberKiroDeliveredFinalAnswer(activeAdapter.name, json);
6514
6590
  rememberResponseState(
6515
6591
  parsed._rawBody,
6516
6592
  json,
@@ -89,7 +89,7 @@ function finishFailedPolicyAttempt(logCtx: RequestLogContext, status: number): v
89
89
  const attempt = logCtx.activeAttempt;
90
90
  if (attempt) {
91
91
  const startedAt = logCtx.activeAttemptStartedAt ?? Date.now();
92
- finishRequestAttempt(attempt, status, Math.max(0, Date.now() - startedAt), attempt.usage ?? logCtx.usage, logCtx.upstreamError);
92
+ finishRequestAttempt(attempt, status, Math.max(0, Date.now() - startedAt), attempt.usage ?? logCtx.usage);
93
93
  }
94
94
  delete logCtx.activeAttempt;
95
95
  delete logCtx.activeAttemptStartedAt;
package/src/service.ts CHANGED
@@ -19,6 +19,7 @@ import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV, durableBunRuntime } from
19
19
  import type { BunRuntimeSource } from "./lib/bun-runtime";
20
20
  import { isProcessAlive, stopProxy } from "./lib/process-control";
21
21
  import { serviceApiTokenFilePath } from "./lib/service-secrets";
22
+ import { tokenCollidesWithAdmin } from "./lib/admin-secrets";
22
23
  import { PROXY_ENV_KEYS } from "./lib/proxy-env";
23
24
  import { randomUUID } from "node:crypto";
24
25
  import {
@@ -366,8 +367,38 @@ export function serviceRetryCommand(
366
367
  return diag.installed && !diag.conflict ? "ocx service repair" : "ocx service install";
367
368
  }
368
369
 
370
+ /**
371
+ * Refuse a management (admin) token as the data-plane secret.
372
+ *
373
+ * The service exports the contents of the service token file as
374
+ * `OPENCODEX_API_AUTH_TOKEN` before starting the proxy. When that value is the admin
375
+ * token, the server treats the management credential as a data-plane admission secret
376
+ * and fails the ENTIRE management plane closed at boot, so every `/api/*` request
377
+ * returns 503 — even on a loopback install that never needed a data-plane secret.
378
+ * Exporting the admin token in the CLI cannot recover it, because the fence is decided
379
+ * server-side at startup (#2696).
380
+ *
381
+ * Nothing in this codebase puts an admin token in that env var; it arrives from the
382
+ * installing shell. This function is the chokepoint that should refuse it rather than
383
+ * writing a file that produces a broken service. Comparison is the same helper doctor
384
+ * uses: minted `ocx_admin_…` prefix, or byte-equal to configuredAdminToken (env or file).
385
+ */
386
+ export function assertNotAdminToken(token: string, env: NodeJS.ProcessEnv = process.env): void {
387
+ if (!tokenCollidesWithAdmin(token, env)) return;
388
+ throw new Error(
389
+ "OPENCODEX_API_AUTH_TOKEN holds a management (admin) token. The service exports it "
390
+ + "as the data-plane secret, which fences the whole management API closed and makes "
391
+ + "every ocx management command fail with 503. Unset OPENCODEX_API_AUTH_TOKEN, or set "
392
+ + "it to a distinct data-plane key, then rerun the install.",
393
+ );
394
+ }
395
+
369
396
  export function assertServiceAuthEnvironment(): void {
370
397
  const config = loadConfig();
398
+ // Check the collision before the loopback short-circuit: a loopback install writes
399
+ // the token file too, so returning early here is what let the broken state through.
400
+ const present = process.env.OPENCODEX_API_AUTH_TOKEN?.trim();
401
+ if (present) assertNotAdminToken(present);
371
402
  if (isLoopbackHostname(config.hostname)) return;
372
403
  if (process.env.OPENCODEX_API_AUTH_TOKEN?.trim()) return;
373
404
  // Reached from `service repair` as well as `install`, so name a command that can
@@ -383,6 +414,9 @@ export function assertServiceAuthEnvironment(): void {
383
414
  function writeServiceApiTokenFile(): string | null {
384
415
  const token = process.env.OPENCODEX_API_AUTH_TOKEN?.trim();
385
416
  if (!token) return null;
417
+ // Last line of defence: every install/repair path funnels through here, so a
418
+ // collision cannot reach disk regardless of which caller ran (#2696).
419
+ assertNotAdminToken(token);
386
420
  const path = serviceApiTokenFilePath();
387
421
  const dir = getConfigDir();
388
422
  recordOwnedConfigPath(dir, path);
@@ -38,6 +38,7 @@ export interface PolicyJobOutcome {
38
38
  freedBytes?: number;
39
39
  removed?: number;
40
40
  trashDir?: string;
41
+ metadataPersistenceError?: PolicyRunResult["metadataPersistenceError"];
41
42
  }
42
43
 
43
44
  export interface PolicyJobState {
@@ -246,6 +247,7 @@ export async function abortStorageCleanupPolicyJobAsync(): Promise<void> {
246
247
  }
247
248
  }
248
249
 
250
+ /** Project a run result into the bounded management-API job outcome. */
249
251
  function outcomeFromResult(result: PolicyRunResult): PolicyJobOutcome {
250
252
  return {
251
253
  ok: result.ok,
@@ -256,18 +258,26 @@ function outcomeFromResult(result: PolicyRunResult): PolicyJobOutcome {
256
258
  ...(result.freedBytes !== undefined ? { freedBytes: result.freedBytes } : {}),
257
259
  ...(result.removed !== undefined ? { removed: result.removed } : {}),
258
260
  ...(result.trashDir ? { trashDir: result.trashDir } : {}),
261
+ ...(result.metadataPersistenceError
262
+ ? { metadataPersistenceError: result.metadataPersistenceError }
263
+ : {}),
259
264
  };
260
265
  }
261
266
 
267
+ /** Publish one completed evaluation without losing successful cleanup effects. */
262
268
  function applyFinished(result: PolicyRunResult): void {
263
269
  // Prefer the latest persisted policy over `result.policy`. The worker (or
264
270
  // in-process run) already merged run metadata into disk; a concurrent PUT
265
271
  // may also have landed after that write. Re-reading avoids applying a stale
266
272
  // start-of-job snapshot when the run skipped without saving.
267
- try {
268
- livePolicyApply?.(readStorageCleanupPolicyFromConfig());
269
- } catch {
270
- livePolicyApply?.(result.policy);
273
+ // A best-effort fallback policy may predate concurrent edits; keep the current
274
+ // live config untouched when the durable metadata write did not land.
275
+ if (!result.metadataPersistenceError) {
276
+ try {
277
+ livePolicyApply?.(readStorageCleanupPolicyFromConfig());
278
+ } catch {
279
+ livePolicyApply?.(result.policy);
280
+ }
271
281
  }
272
282
  state = {
273
283
  status: "idle",
@@ -53,6 +53,7 @@ function adoptStorageCleanupPolicy(policy: StorageCleanupPolicy): void {
53
53
  }
54
54
 
55
55
  export type PolicyRunReason = "startup" | "schedule" | "manual";
56
+ export type PolicyMetadataPersistenceError = "missing" | "invalid" | "conflict" | "write_failed";
56
57
 
57
58
  export type PolicySkipReason =
58
59
  | "disabled"
@@ -69,6 +70,7 @@ export interface PolicyRunResult {
69
70
  freedBytes?: number;
70
71
  removed?: number;
71
72
  trashDir?: string;
73
+ metadataPersistenceError?: PolicyMetadataPersistenceError;
72
74
  policy: StorageCleanupPolicy;
73
75
  }
74
76
 
@@ -230,14 +232,18 @@ export type PolicyRunMetadataPatch = {
230
232
  lastRun?: StorageCleanupPolicy["lastRun"];
231
233
  };
232
234
 
235
+ /** Apply only run-owned fields while preserving the supplied policy settings. */
233
236
  function applyPolicyRunMetadata(
234
- latest: StorageCleanupPolicy,
237
+ policy: StorageCleanupPolicy,
235
238
  patch: PolicyRunMetadataPatch,
236
239
  ): StorageCleanupPolicy {
237
- let next = patch.nextRun === "defer_busy"
238
- ? deferBusy(latest, patch.now)
239
- : advanceNextRun(latest, patch.now);
240
- if (patch.lastRun) next = { ...next, lastRun: patch.lastRun };
240
+ let next =
241
+ patch.nextRun === "defer_busy"
242
+ ? deferBusy(policy, patch.now)
243
+ : advanceNextRun(policy, patch.now);
244
+ if (patch.lastRun) {
245
+ next = { ...next, lastRun: patch.lastRun };
246
+ }
241
247
  return next;
242
248
  }
243
249
 
@@ -257,16 +263,51 @@ export function commitPolicyRunMetadata(
257
263
  return next;
258
264
  }
259
265
 
260
- function commitPersistedPolicyRunMetadata(patch: PolicyRunMetadataPatch): StorageCleanupPolicy {
261
- const outcome = mutatePersistedConfig(fresh => {
262
- const before = JSON.stringify(fresh.storageCleanupPolicy);
263
- const next = applyPolicyRunMetadata(normalizeStorageCleanupPolicy(fresh.storageCleanupPolicy), patch);
264
- fresh.storageCleanupPolicy = next;
265
- return { changed: JSON.stringify(next) !== before, value: structuredClone(next) };
266
- });
267
- if (outcome.status === "unavailable") throw new Error(`storage cleanup policy persistence unavailable: ${outcome.reason}`);
268
- adoptStorageCleanupPolicy(outcome.value);
269
- return outcome.value;
266
+ type PolicyRunMetadataCommit = {
267
+ policy: StorageCleanupPolicy;
268
+ persistenceError?: PolicyMetadataPersistenceError;
269
+ };
270
+
271
+ /** Attach the durable metadata outcome without replacing cleanup status or metrics. */
272
+ function withMetadataCommit(
273
+ result: Omit<PolicyRunResult, "policy" | "metadataPersistenceError">,
274
+ committed: PolicyRunMetadataCommit,
275
+ ): PolicyRunResult {
276
+ return {
277
+ ...result,
278
+ policy: committed.policy,
279
+ ...(committed.persistenceError ? { metadataPersistenceError: committed.persistenceError } : {}),
280
+ };
281
+ }
282
+
283
+ /** Recompute run-owned metadata from the latest config inside the mutation lock. */
284
+ function commitPolicyRunMetadataToConfig(
285
+ patch: PolicyRunMetadataPatch,
286
+ fallbackPolicy: StorageCleanupPolicy,
287
+ ): PolicyRunMetadataCommit {
288
+ const unavailable = (reason: PolicyMetadataPersistenceError): PolicyRunMetadataCommit => {
289
+ console.warn(`[storage-policy] metadata_persist_failed reason=${reason}`);
290
+ return {
291
+ policy: applyPolicyRunMetadata(fallbackPolicy, patch),
292
+ persistenceError: reason,
293
+ };
294
+ };
295
+ try {
296
+ const outcome = mutatePersistedConfig(config => {
297
+ const before = JSON.stringify(config.storageCleanupPolicy);
298
+ const next = applyPolicyRunMetadata(
299
+ normalizeStorageCleanupPolicy(config.storageCleanupPolicy),
300
+ patch,
301
+ );
302
+ config.storageCleanupPolicy = next;
303
+ return { changed: JSON.stringify(next) !== before, value: structuredClone(next) };
304
+ });
305
+ if (outcome.status === "unavailable") return unavailable(outcome.reason);
306
+ adoptStorageCleanupPolicy(outcome.value);
307
+ return { policy: outcome.value };
308
+ } catch {
309
+ return unavailable("write_failed");
310
+ }
270
311
  }
271
312
 
272
313
  function logPolicyEvent(message: string): void {
@@ -281,11 +322,14 @@ export function runStorageCleanupPolicy(deps: PolicyRunDeps): PolicyRunResult {
281
322
  const now = deps.now ?? Date.now();
282
323
  const load = deps.loadPolicy ?? readStorageCleanupPolicyFromConfig;
283
324
  const execute = deps.execute ?? executeArchivedCleanup;
284
- const commit = deps.savePolicy
285
- ? (patch: PolicyRunMetadataPatch) => commitPolicyRunMetadata(load, deps.savePolicy!, patch)
286
- : commitPersistedPolicyRunMetadata;
287
-
288
325
  const policy = normalizeStorageCleanupPolicy(load());
326
+ // Injected stores retain the existing load/save contract. The production path
327
+ // recomputes metadata from the latest persisted policy inside the config lock.
328
+ const commitMetadata = deps.savePolicy !== undefined
329
+ ? (patch: PolicyRunMetadataPatch): PolicyRunMetadataCommit => ({
330
+ policy: commitPolicyRunMetadata(load, deps.savePolicy!, patch),
331
+ })
332
+ : (patch: PolicyRunMetadataPatch) => commitPolicyRunMetadataToConfig(patch, policy);
289
333
  deps.onPolicyLoaded?.();
290
334
 
291
335
  if (typeof deps.holdAfterLoadMs === "number" && Number.isFinite(deps.holdAfterLoadMs) && deps.holdAfterLoadMs > 0) {
@@ -302,15 +346,15 @@ export function runStorageCleanupPolicy(deps: PolicyRunDeps): PolicyRunResult {
302
346
 
303
347
  const selection = selectPolicyPreview(policy, deps.codexHome);
304
348
  if (selection.archivedBytes <= policy.trigger.archivedBytesOver) {
305
- const saved = commit({ now, nextRun: "advance" });
349
+ const committed = commitMetadata({ now, nextRun: "advance" });
306
350
  logPolicyEvent("skip under_threshold");
307
- return { ok: true, skipped: "under_threshold", policy: saved };
351
+ return withMetadataCommit({ ok: true, skipped: "under_threshold" }, committed);
308
352
  }
309
353
 
310
354
  if (selection.count === 0) {
311
- const saved = commit({ now, nextRun: "advance" });
355
+ const committed = commitMetadata({ now, nextRun: "advance" });
312
356
  logPolicyEvent("skip nothing_selected");
313
- return { ok: true, skipped: "nothing_selected", policy: saved };
357
+ return withMetadataCommit({ ok: true, skipped: "nothing_selected" }, committed);
314
358
  }
315
359
 
316
360
  const result = execute({
@@ -324,25 +368,28 @@ export function runStorageCleanupPolicy(deps: PolicyRunDeps): PolicyRunResult {
324
368
  });
325
369
 
326
370
  if (!result.ok && result.error === "codex_busy") {
327
- const saved = commit({ now, nextRun: "defer_busy" });
371
+ const committed = commitMetadata({ now, nextRun: "defer_busy" });
328
372
  logPolicyEvent("defer codex_busy");
329
- return { ok: false, deferred: "codex_busy", error: "codex_busy", policy: saved };
373
+ return withMetadataCommit({
374
+ ok: false,
375
+ deferred: "codex_busy",
376
+ error: "codex_busy",
377
+ }, committed);
330
378
  }
331
379
 
332
380
  if (!result.ok) {
333
381
  // Non-busy failure: still advance schedule so we do not tight-loop.
334
- const saved = commit({ now, nextRun: "advance" });
382
+ const committed = commitMetadata({ now, nextRun: "advance" });
335
383
  logPolicyEvent(`fail ${result.error ?? "cleanup_failed"}`);
336
- return {
384
+ return withMetadataCommit({
337
385
  ok: false,
338
386
  error: result.error,
339
387
  mode: result.mode,
340
388
  ...(result.trashDir ? { trashDir: result.trashDir } : {}),
341
- policy: saved,
342
- };
389
+ }, committed);
343
390
  }
344
391
 
345
- const saved = commit({
392
+ const committed = commitMetadata({
346
393
  now,
347
394
  nextRun: "advance",
348
395
  lastRun: {
@@ -354,14 +401,13 @@ export function runStorageCleanupPolicy(deps: PolicyRunDeps): PolicyRunResult {
354
401
  logPolicyEvent(
355
402
  `ok mode=${result.mode} removed=${result.count} freedBytes=${result.bytes}`,
356
403
  );
357
- return {
404
+ return withMetadataCommit({
358
405
  ok: true,
359
406
  mode: result.mode,
360
407
  freedBytes: result.bytes,
361
408
  removed: result.count,
362
409
  ...(result.trashDir ? { trashDir: result.trashDir } : {}),
363
- policy: saved,
364
- };
410
+ }, committed);
365
411
  }
366
412
 
367
413
  /** Startup / schedule tick entry — swallows unexpected errors. */
package/src/usage/log.ts CHANGED
@@ -8,22 +8,27 @@ import { sanitizeLogMetadataString } from "../lib/redact";
8
8
  import { usageDisplayTotalTokens } from "./totals";
9
9
  import type { AttemptTierOutcome, OcxUsage } from "../types";
10
10
  import { normalizeRouteDecisionTrace, type RouteDecisionTraceV1 } from "../routing/trace";
11
- import { CODEX_ACCOUNT_LOG_LABEL_RE } from "../codex/account-label";
11
+ import { ACCOUNT_LOG_LABEL_RE, CODEX_ACCOUNT_LOG_LABEL_RE } from "../codex/account-label";
12
12
  import type { AgentKind } from "../server/effort-policy";
13
13
 
14
14
  export type UsageStatus = "reported" | "unreported" | "unsupported" | "estimated";
15
- export type CodexUsageAccountLogLabel = "main" | `p${string}`;
15
+ export type UsageAccountLogLabel = "main" | `p${string}` | `o${string}`;
16
+ export type CodexUsageAccountLogLabel = UsageAccountLogLabel;
16
17
 
17
18
  export const OAUTH_ACCOUNT_LOG_LABEL_RE = /^[a-f0-9]{8,64}(?:-\d+)?$/;
18
19
 
19
20
  export function isPersistableAccountLogLabel(value: unknown): value is string {
20
21
  return (
21
- value === "main"
22
- || (typeof value === "string" && (CODEX_ACCOUNT_LOG_LABEL_RE.test(value) || OAUTH_ACCOUNT_LOG_LABEL_RE.test(value)))
22
+ typeof value === "string"
23
+ && (ACCOUNT_LOG_LABEL_RE.test(value) || OAUTH_ACCOUNT_LOG_LABEL_RE.test(value))
23
24
  );
24
25
  }
25
26
 
26
- export function isCodexUsageAccountLogLabel(value: unknown): value is CodexUsageAccountLogLabel {
27
+ export function isCodexUsageAccountLogLabel(value: unknown): value is UsageAccountLogLabel {
28
+ return typeof value === "string" && ACCOUNT_LOG_LABEL_RE.test(value);
29
+ }
30
+
31
+ export function isCodexPoolAccountLogLabel(value: unknown): value is "main" | `p${string}` {
27
32
  return value === "main" || (typeof value === "string" && CODEX_ACCOUNT_LOG_LABEL_RE.test(value));
28
33
  }
29
34
 
@@ -63,6 +68,13 @@ export interface PersistedUsageAttempt {
63
68
  sendCount: number;
64
69
  recoveryKinds: AttemptRecoveryKind[];
65
70
  usageStatus: UsageStatus;
71
+ /**
72
+ * True when the proxy answered this turn locally and issued no upstream request. It travels on
73
+ * the attempt itself rather than as a `finishRequestAttempt` argument because that function is
74
+ * called from six places, and a new parameter would silently default to the wrong answer at any
75
+ * one of them that was missed. Absent on ordinary attempts so old rows keep their exact shape.
76
+ */
77
+ locallyAnswered?: boolean;
66
78
  /** Stable non-PII identity for the account that served this attempt. */
67
79
  accountLogLabel?: string;
68
80
  inputTokenEstimate?: number;
@@ -200,8 +212,20 @@ function isEstimatedUsageProvider(providerOrAdapter: string): boolean {
200
212
  || providerOrAdapter === "cursor" || providerOrAdapter.startsWith("cursor-");
201
213
  }
202
214
 
203
- export function usageForFinalLog(provider: string, usage: OcxUsage | undefined): OcxUsage | undefined {
215
+ export function usageForFinalLog(
216
+ provider: string,
217
+ usage: OcxUsage | undefined,
218
+ /**
219
+ * True when the proxy answered this turn locally and issued no upstream request. Such a turn's
220
+ * zero counts are EXACT, so the provider-wide estimated marking must not apply: Kiro and Cursor
221
+ * are marked estimated because their adapters can only guess a real inference's usage, and a
222
+ * turn with no inference has nothing to guess. Without this, a no-send turn is indistinguishable
223
+ * from a real one whose usage frame never arrived.
224
+ */
225
+ locallyAnswered = false,
226
+ ): OcxUsage | undefined {
204
227
  if (!usage) return undefined;
228
+ if (locallyAnswered) return usage;
205
229
  if (usage.estimated || isEstimatedUsageProvider(provider)) return { ...usage, estimated: true };
206
230
  return usage;
207
231
  }
@@ -239,6 +263,8 @@ const ATTEMPT_RECOVERY_KINDS = new Set<AttemptRecoveryKind>([
239
263
  "key-429",
240
264
  "rate-limit-429",
241
265
  "anthropic-oauth-429",
266
+ "cursor-oauth-auth",
267
+ "cursor-oauth-429",
242
268
  "oauth-account-429",
243
269
  "image-413",
244
270
  "opaque-blob-rejection",