@bitkyc08/opencodex 2.34.0 → 2.35.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 (71) hide show
  1. package/gui/dist/assets/{index-C4TMRloX.js → index-DNdRKXK9.js} +11 -11
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +3 -1
  4. package/src/adapters/base.ts +26 -0
  5. package/src/adapters/cursor/catalog.ts +541 -0
  6. package/src/adapters/cursor/cursor-errors.ts +15 -0
  7. package/src/adapters/cursor/discovery.ts +34 -41
  8. package/src/adapters/cursor/envelope-echo.ts +128 -0
  9. package/src/adapters/cursor/request-builder.ts +19 -12
  10. package/src/adapters/cursor/tool-definitions.ts +2 -1
  11. package/src/adapters/cursor/tool-result-normalize.ts +23 -31
  12. package/src/adapters/cursor.ts +21 -2
  13. package/src/adapters/exec-tool-result-normalize.ts +99 -0
  14. package/src/adapters/google-antigravity-replay.ts +71 -2
  15. package/src/adapters/google-antigravity-wire.ts +5 -0
  16. package/src/adapters/google.ts +15 -1
  17. package/src/adapters/kiro-constants.ts +12 -0
  18. package/src/adapters/kiro.ts +128 -11
  19. package/src/adapters/openai-chat.ts +16 -2
  20. package/src/adapters/openai-responses.ts +15 -2
  21. package/src/adapters/run-turn-queue.ts +36 -1
  22. package/src/adapters/tool-catalog-nudge.ts +2 -1
  23. package/src/adapters/xai-web-search.ts +10 -14
  24. package/src/claude/outbound.ts +14 -3
  25. package/src/cli/access.ts +46 -3
  26. package/src/cli/account-api.ts +84 -15
  27. package/src/cli/account-extended.ts +261 -28
  28. package/src/cli/account-main.ts +12 -12
  29. package/src/cli/account.ts +40 -10
  30. package/src/cli/agent.ts +8 -1
  31. package/src/cli/capabilities-command.ts +94 -0
  32. package/src/cli/capabilities.ts +496 -0
  33. package/src/cli/claude-desktop.ts +31 -11
  34. package/src/cli/dispatch.ts +195 -27
  35. package/src/cli/doctor.ts +100 -1
  36. package/src/cli/help.ts +11 -2
  37. package/src/cli/index.ts +19 -3
  38. package/src/cli/inspect.ts +230 -0
  39. package/src/cli/observe.ts +11 -3
  40. package/src/cli/registry.ts +34 -2
  41. package/src/cli/runtime-api.ts +51 -7
  42. package/src/cli/status.ts +16 -0
  43. package/src/cli/storage.ts +234 -0
  44. package/src/cli/system-command.ts +16 -0
  45. package/src/cli/usage-report.ts +52 -2
  46. package/src/cli/version-skew.ts +46 -0
  47. package/src/codex/account-label.ts +21 -0
  48. package/src/codex/catalog/provider-fetch.ts +4 -0
  49. package/src/codex/transition-state.ts +12 -3
  50. package/src/compatibility/openai-responses.ts +9 -1
  51. package/src/generated/compatibility-version.json +95 -59
  52. package/src/integrations/ownership-policy.ts +24 -5
  53. package/src/integrations/ownership.ts +36 -2
  54. package/src/integrations/state.ts +40 -7
  55. package/src/integrations/writer.ts +21 -3
  56. package/src/lib/admin-secrets.ts +24 -0
  57. package/src/lib/errors.ts +25 -1
  58. package/src/lib/service-secrets.ts +15 -0
  59. package/src/oauth/store.ts +14 -5
  60. package/src/providers/label.ts +34 -1
  61. package/src/responses/turn-termination.ts +107 -0
  62. package/src/server/management/logs-usage-routes.ts +0 -16
  63. package/src/server/management/route-registry.ts +311 -0
  64. package/src/server/proxy-liveness.ts +27 -4
  65. package/src/server/request-log.ts +29 -1
  66. package/src/server/responses/core.ts +80 -0
  67. package/src/service.ts +34 -0
  68. package/src/storage/policy-job.ts +14 -4
  69. package/src/storage/policy.ts +88 -23
  70. package/src/usage/log.ts +44 -4
  71. package/src/usage/summary.ts +10 -0
@@ -12,10 +12,11 @@ import { ClientPathError, EXPORT_CLIENTS, opencodeProxyBaseUrl, type ExportModel
12
12
  import type { OcxConfig } from "../types";
13
13
  import { PARSE_FAILED, loadTarget, parseConfig, type IntegrationIO } from "./config-io";
14
14
  import { SNAPSHOT_RETENTION } from "./journal";
15
- import { canonicalContribution, fingerprint, type OwnershipRecord } from "./ownership";
15
+ import { canonicalContribution, fingerprint, semanticContribution, type OwnershipRecord } from "./ownership";
16
16
  import {
17
17
  protectedContributionFingerprint,
18
18
  refreshablePathsOf,
19
+ semanticProtectedContributionFingerprint,
19
20
  validRefreshablePaths,
20
21
  } from "./ownership-policy";
21
22
  import { INTEGRATION_CLIENTS, type IntegrationClientId } from "./registry";
@@ -153,21 +154,49 @@ function recordedBlockIsOwned(
153
154
  if (!observed) return false;
154
155
  if (fingerprint(canonicalContribution(observed)) === record.blockFingerprint) return true;
155
156
 
157
+ const observedSemanticFingerprint = fingerprint(semanticContribution(observed));
158
+ if (
159
+ typeof record.semanticBlockFingerprint === "string"
160
+ && observedSemanticFingerprint === record.semanticBlockFingerprint
161
+ ) return true;
162
+
163
+ const desiredFingerprint = fingerprint(canonicalContribution(desired));
164
+ if (
165
+ desiredFingerprint === record.blockFingerprint
166
+ && observedSemanticFingerprint === fingerprint(semanticContribution(desired))
167
+ ) return true;
168
+
156
169
  if (
157
170
  typeof record.protectedBlockFingerprint === "string"
158
171
  && validRefreshablePaths(observed, record.refreshablePaths)
159
172
  && record.refreshablePaths.length > 0
160
173
  ) {
161
- return protectedContributionFingerprint(observed, record.refreshablePaths)
162
- === record.protectedBlockFingerprint;
174
+ const observedProtectedFingerprint = protectedContributionFingerprint(
175
+ observed,
176
+ record.refreshablePaths,
177
+ );
178
+ if (observedProtectedFingerprint === record.protectedBlockFingerprint) return true;
179
+
180
+ const observedSemanticProtectedFingerprint = semanticProtectedContributionFingerprint(
181
+ observed,
182
+ record.refreshablePaths,
183
+ );
184
+ if (
185
+ typeof record.semanticProtectedBlockFingerprint === "string"
186
+ && observedSemanticProtectedFingerprint === record.semanticProtectedBlockFingerprint
187
+ ) return true;
188
+
189
+ return protectedContributionFingerprint(desired, record.refreshablePaths)
190
+ === record.protectedBlockFingerprint
191
+ && observedSemanticProtectedFingerprint
192
+ === semanticProtectedContributionFingerprint(desired, record.refreshablePaths);
163
193
  }
164
194
 
165
- const desiredFingerprint = fingerprint(canonicalContribution(desired));
166
195
  if (desiredFingerprint !== record.blockFingerprint) return false;
167
196
  const legacyPaths = refreshablePathsOf(desired);
168
197
  return legacyPaths.length > 0
169
- && protectedContributionFingerprint(observed, legacyPaths)
170
- === protectedContributionFingerprint(desired, legacyPaths);
198
+ && semanticProtectedContributionFingerprint(observed, legacyPaths)
199
+ === semanticProtectedContributionFingerprint(desired, legacyPaths);
171
200
  }
172
201
 
173
202
  /**
@@ -257,7 +286,11 @@ export function classifyIntegration(input: {
257
286
  }
258
287
  return { state: "stale" };
259
288
  }
260
- return input.record.blockFingerprint === fingerprint(canonicalContribution(input.contribution))
289
+ const desiredFingerprint = typeof input.record.semanticBlockFingerprint === "string"
290
+ ? fingerprint(semanticContribution(input.contribution))
291
+ : fingerprint(canonicalContribution(input.contribution));
292
+ const recordedFingerprint = input.record.semanticBlockFingerprint ?? input.record.blockFingerprint;
293
+ return recordedFingerprint === desiredFingerprint
261
294
  ? { state: "current" }
262
295
  : { state: "stale" };
263
296
  }
@@ -15,8 +15,18 @@ import { EXPORT_CLIENTS, type ExportModel, type ManagedContribution } from "../c
15
15
  import { isLoopbackHostname } from "../codex/inject";
16
16
  import type { OcxConfig } from "../types";
17
17
  import { PARSE_FAILED, defaultIntegrationIO, loadTarget, parseConfig, type IntegrationIO } from "./config-io";
18
- import { fingerprint, canonicalContribution, fragmentPathsOf, type OwnershipRecord } from "./ownership";
19
- import { protectedContributionFingerprint, refreshablePathsOf } from "./ownership-policy";
18
+ import {
19
+ fingerprint,
20
+ canonicalContribution,
21
+ fragmentPathsOf,
22
+ semanticContribution,
23
+ type OwnershipRecord,
24
+ } from "./ownership";
25
+ import {
26
+ protectedContributionFingerprint,
27
+ refreshablePathsOf,
28
+ semanticProtectedContributionFingerprint,
29
+ } from "./ownership-policy";
20
30
  import { createdContainerPaths, mergeContribution, removeFragments } from "./merge";
21
31
  import { INTEGRATION_CLIENTS, isLoopbackOnly, type IntegrationClientId } from "./registry";
22
32
  import { classifyIntegration, exportContextOf } from "./state";
@@ -361,8 +371,13 @@ function applyOrRefreshIntegration(input: IntegrationWriteInput, allowAbsent: bo
361
371
  record: {
362
372
  clientId, configPath, fileFingerprint: fingerprint(text),
363
373
  blockFingerprint: fingerprint(canonicalContribution(contribution)),
374
+ semanticBlockFingerprint: fingerprint(semanticContribution(contribution)),
364
375
  ...(refreshablePaths.length > 0 ? {
365
376
  protectedBlockFingerprint: protectedContributionFingerprint(contribution, refreshablePaths),
377
+ semanticProtectedBlockFingerprint: semanticProtectedContributionFingerprint(
378
+ contribution,
379
+ refreshablePaths,
380
+ ),
366
381
  refreshablePaths,
367
382
  } : {}),
368
383
  fragmentPaths: fragmentPathsOf(contribution), createdContainers: created,
@@ -556,7 +571,10 @@ export function restoreIntegration(input: IntegrationRestoreInput): WriteOutcome
556
571
  ? (restoredText === null ? "absent" : "conflict")
557
572
  : !recordDescribesBytes
558
573
  ? "conflict"
559
- : restoredRecord.blockFingerprint === fingerprint(canonicalContribution(fresh))
574
+ : (
575
+ restoredRecord.semanticBlockFingerprint === fingerprint(semanticContribution(fresh))
576
+ || restoredRecord.blockFingerprint === fingerprint(canonicalContribution(fresh))
577
+ )
560
578
  ? "current"
561
579
  : "stale";
562
580
 
@@ -1,3 +1,4 @@
1
+ import { timingSafeEqual } from "node:crypto";
1
2
  import { lstatSync, readFileSync } from "node:fs";
2
3
  import { join } from "node:path";
3
4
  import { getConfigDir } from "../config";
@@ -23,3 +24,26 @@ export function loadAdminTokenFromFile(configDir = getConfigDir()): string | nul
23
24
  export function configuredAdminToken(configDir = getConfigDir(), env: NodeJS.ProcessEnv = process.env): string | null {
24
25
  return env.OPENCODEX_ADMIN_AUTH_TOKEN?.trim() || loadAdminTokenFromFile(configDir);
25
26
  }
27
+
28
+ export const ADMIN_TOKEN_PREFIX = "ocx_admin_";
29
+
30
+ function secretTextEquals(left: string, right: string): boolean {
31
+ const a = Buffer.from(left);
32
+ const b = Buffer.from(right);
33
+ return a.length === b.length && timingSafeEqual(a, b);
34
+ }
35
+
36
+ /**
37
+ * True when `token` is a management credential: minted `ocx_admin_…` shape, or
38
+ * byte-equal to the configured admin token (env or admin-api-token file).
39
+ * Used by the service write/start chokepoint and by doctor so the two cannot drift.
40
+ */
41
+ export function tokenCollidesWithAdmin(
42
+ token: string,
43
+ env: NodeJS.ProcessEnv = process.env,
44
+ configDir = getConfigDir(),
45
+ ): boolean {
46
+ if (token.startsWith(ADMIN_TOKEN_PREFIX)) return true;
47
+ const admin = configuredAdminToken(configDir, env);
48
+ return admin !== null && secretTextEquals(token, admin);
49
+ }
package/src/lib/errors.ts CHANGED
@@ -335,6 +335,12 @@ export function inferHttpStatusFromAdapterMessage(message: string): number {
335
335
  // subscription/permission wording.
336
336
  if (isAuthenticationMessage(lower)) return 401;
337
337
  if (isSubscriptionGateMessage(lower) || isPermissionMessage(lower)) return 403;
338
+ // Same precedence rule as classifyCursorError: an explicit gRPC FAILED_PRECONDITION is a
339
+ // structured, deterministic rejection, so it outranks the overload keywords that routinely
340
+ // appear beside it ("failed_precondition: model unavailable for this plan"). Without this,
341
+ // the message matched "unavailable" and returned a retryable 503, so clients kept retrying
342
+ // a rejection that can never succeed.
343
+ if (lower.includes("failed_precondition") || lower.includes("failed precondition")) return 400;
338
344
  if (
339
345
  lower.includes("unavailable") ||
340
346
  lower.includes("overloaded") ||
@@ -410,6 +416,24 @@ export function httpStatusFromTerminalError(error: {
410
416
  if (message && isClientClosedMessage(message)) return 499;
411
417
  if (error.type === "invalid_request_error") return 400;
412
418
  if (error.type === "proxy_error") return 500;
413
- if (message) return inferHttpStatusFromAdapterMessage(message);
419
+ // A structured server class must not be downgraded to a CLIENT error by message wording.
420
+ // classifyError assigns `server_error` + `upstream_server_error` to every 5xx it sees, so
421
+ // the class is authoritative about blame: the upstream failed, the caller did not send a
422
+ // bad request. What it is NOT authoritative about is which server status fits — a stall is
423
+ // genuinely 504 and an overload genuinely 503, and flattening those to 502 discards
424
+ // information both the log surface and the retry policy read. So message inference still
425
+ // chooses the specific status, and only a client-error verdict is overridden.
426
+ //
427
+ // The override is deliberately narrowed to 400 alone. 429, 499, 401 and 403 are all
428
+ // actionable signals the caller routes on — retry-after, client cancellation, re-auth,
429
+ // entitlement — and overriding them would trade one kind of misreport for another. 400 is
430
+ // the single verdict that both blames the caller and stops the retry, which is the failure
431
+ // being fixed: an upstream 500 whose text happens to contain "malformed" or "invalid
432
+ // request" used to return 400, so Claude Code stopped retrying a retryable failure.
433
+ const structuredServerClass = error.type === "server_error" || error.code === "upstream_server_error";
434
+ if (message) {
435
+ const inferred = inferHttpStatusFromAdapterMessage(message);
436
+ return structuredServerClass && inferred === 400 ? 502 : inferred;
437
+ }
414
438
  return 502;
415
439
  }
@@ -23,3 +23,18 @@ export function loadServiceTokenFromFile(env: Record<string, string | undefined>
23
23
  return null;
24
24
  }
25
25
  }
26
+
27
+ /**
28
+ * Contents of the installed service token file. The launch wrapper always re-exports
29
+ * this file as OPENCODEX_API_AUTH_TOKEN, so doctor and start must inspect it even
30
+ * when the calling shell has no data-plane env var.
31
+ * Returns the token or null — never throws, never logs the value.
32
+ */
33
+ export function readInstalledServiceToken(): string | null {
34
+ try {
35
+ const token = readFileSync(serviceApiTokenFilePath(), "utf8").trim();
36
+ return token || null;
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
@@ -591,17 +591,26 @@ export async function upsertCredentialByIdentity(
591
591
  }, [provider, safe]);
592
592
  }
593
593
 
594
- /** Remove the ACTIVE account; remaining accounts promote the first one. */
595
- export async function removeCredential(provider: string): Promise<void> {
596
- await mutateStore(store => {
594
+ /**
595
+ * Remove the ACTIVE account; remaining accounts promote the first one.
596
+ *
597
+ * Returns what actually happened, which a caller cannot otherwise know. A read-then-remove
598
+ * preflight is not equivalent: `mutateStore` serializes mutations, so between a caller's
599
+ * `getAccountSet` check and its `removeCredential` call another process can remove the same
600
+ * account, and both callers would then report a removal that only one of them performed.
601
+ * Deciding inside the mutation is the only place the answer is true when it is returned.
602
+ */
603
+ export async function removeCredential(provider: string): Promise<"removed" | "not-found"> {
604
+ return await mutateStore(store => {
597
605
  const set = store[provider];
598
- if (!set) return;
606
+ if (!set) return "not-found" as const;
599
607
  set.accounts = set.accounts.filter(a => a.id !== set.activeAccountId);
600
608
  if (set.accounts.length === 0) {
601
609
  delete store[provider];
602
- return;
610
+ return "removed" as const;
603
611
  }
604
612
  set.activeAccountId = set.accounts[0]!.id;
613
+ return "removed" as const;
605
614
  }, [provider]);
606
615
  }
607
616
 
@@ -1,4 +1,5 @@
1
- import { CODEX_ACCOUNT_LOG_LABEL_RE } from "../codex/account-label";
1
+ import { CODEX_ACCOUNT_LOG_LABEL_RE, oauthAccountLogLabel } from "../codex/account-label";
2
+ import type { OcxProviderConfig } from "../types";
2
3
 
3
4
  export function canonicalUsageProviderLabel(provider: string): string {
4
5
  return provider === "chatgpt" || provider === "openai-multi" ? "openai" : provider;
@@ -17,3 +18,35 @@ export function baseProviderLabel(provider: string): string {
17
18
  if (suffix === "main") return canonicalUsageProviderLabel(provider.slice(0, cut));
18
19
  return CODEX_ACCOUNT_LOG_LABEL_RE.test(suffix) ? canonicalUsageProviderLabel(provider.slice(0, cut)) : provider;
19
20
  }
21
+
22
+ /**
23
+ * Stamp the per-account usage label for a non-Codex OAuth provider (#2699).
24
+ *
25
+ * Call this where the resolved credential is known and NOT inside a failover gate. The obvious
26
+ * place -- next to the `genericFailoverAccountId` assignment in `core.ts` -- sits inside
27
+ * `isGenericFailoverProvider`, and the rotation paths additionally require two or more stored
28
+ * accounts. Stamping there would leave the ordinary case (one xai account, failover off) with no
29
+ * label at all while every test still passed, which is the bug this fixes rather than a variant
30
+ * of it.
31
+ *
32
+ * Two providers are skipped because they already have attribution:
33
+ * - `openai` produces its own `p`-labels through `codexAuthContextLogLabel`.
34
+ * - `anthropic` folds the account into the provider label (`formatAnthropicProviderForLog`).
35
+ *
36
+ * It lives here rather than in `account-label.ts` because it needs `baseProviderLabel`, and this
37
+ * module already imports from that one -- the reverse direction would be an import cycle. This
38
+ * file stays Lab-clean, which matters because `core.ts` is one of the three files
39
+ * `tests/core-lab-boundary.test.ts` guards.
40
+ */
41
+ export function stampOAuthAccountLabel(
42
+ logCtx: { accountLogLabel?: string },
43
+ providerName: string,
44
+ provider: Pick<OcxProviderConfig, "authMode">,
45
+ accountId: string | undefined,
46
+ ): void {
47
+ if (!accountId) return;
48
+ if (provider.authMode !== "oauth") return;
49
+ const base = baseProviderLabel(providerName);
50
+ if (base === "openai" || base === "anthropic") return;
51
+ logCtx.accountLogLabel = oauthAccountLogLabel(accountId, base);
52
+ }
@@ -0,0 +1,107 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { OcxAssistantMessage, OcxMessage, OcxParsedRequest } from "../types";
3
+
4
+ const DELIVERED_FINAL_ANSWER_TTL_MS = 60 * 60 * 1_000;
5
+ const DELIVERED_FINAL_ANSWER_MAX_ENTRIES = 1_024;
6
+
7
+ interface DeliveredFinalAnswerRecord {
8
+ fingerprint: string;
9
+ createdAt: number;
10
+ }
11
+
12
+ const scopesByRequest = new WeakMap<OcxParsedRequest, string>();
13
+ const deliveredFinalAnswers = new Map<string, DeliveredFinalAnswerRecord>();
14
+
15
+ function pruneDeliveredFinalAnswers(at = Date.now()): void {
16
+ for (const [scope, record] of deliveredFinalAnswers) {
17
+ if (at - record.createdAt > DELIVERED_FINAL_ANSWER_TTL_MS) deliveredFinalAnswers.delete(scope);
18
+ }
19
+ while (deliveredFinalAnswers.size > DELIVERED_FINAL_ANSWER_MAX_ENTRIES) {
20
+ const oldest = deliveredFinalAnswers.keys().next().value;
21
+ if (oldest === undefined) break;
22
+ deliveredFinalAnswers.delete(oldest);
23
+ }
24
+ }
25
+
26
+ function textFingerprint(text: string): string {
27
+ return createHash("sha256").update(text, "utf8").digest("hex");
28
+ }
29
+
30
+ function assistantText(message: OcxAssistantMessage): string | undefined {
31
+ if (message.content.some(part => part.type === "toolCall")) return undefined;
32
+ const text = message.content
33
+ .filter((part): part is Extract<typeof part, { type: "text" }> => part.type === "text")
34
+ .map(part => part.text)
35
+ .join("");
36
+ return text.trim().length > 0 ? text : undefined;
37
+ }
38
+
39
+ function deliveredFinalAnswerText(response: unknown): string | undefined {
40
+ if (!response || typeof response !== "object" || Array.isArray(response)) return undefined;
41
+ const output = (response as { output?: unknown }).output;
42
+ if (!Array.isArray(output)) return undefined;
43
+ for (let i = output.length - 1; i >= 0; i--) {
44
+ const item = output[i];
45
+ if (!item || typeof item !== "object" || Array.isArray(item)) continue;
46
+ const message = item as { type?: unknown; role?: unknown; phase?: unknown; content?: unknown };
47
+ if (message.type !== "message" || message.role !== "assistant" || message.phase !== "final_answer") continue;
48
+ if (!Array.isArray(message.content)) return undefined;
49
+ const text = message.content
50
+ .filter(part => !!part && typeof part === "object" && !Array.isArray(part)
51
+ && (part as { type?: unknown }).type === "output_text"
52
+ && typeof (part as { text?: unknown }).text === "string")
53
+ .map(part => (part as { text: string }).text)
54
+ .join("");
55
+ return text.trim().length > 0 ? text : undefined;
56
+ }
57
+ return undefined;
58
+ }
59
+
60
+ /** Bind the normalized per-conversation digest without adding proxy-private fields to the wire body. */
61
+ export function bindTurnTerminationScope(parsed: OcxParsedRequest, scope: string | undefined): void {
62
+ // Only the normalized log-conversation digest may key this process-wide map. Refusing any raw
63
+ // fallback prevents a future caller from retaining a client header or account identifier here.
64
+ if (!scope || !/^[0-9a-f]{32}$/.test(scope)) return;
65
+ scopesByRequest.set(parsed, scope);
66
+ }
67
+
68
+ /** Remember only a final-answer message the proxy actually emitted for this exact conversation. */
69
+ export function rememberDeliveredFinalAnswer(parsed: OcxParsedRequest, response: unknown): void {
70
+ const scope = scopesByRequest.get(parsed);
71
+ if (!scope) return;
72
+ const text = deliveredFinalAnswerText(response);
73
+ if (!text) return;
74
+ const at = Date.now();
75
+ pruneDeliveredFinalAnswers(at);
76
+ // Refresh insertion order so cap eviction removes the least recently delivered conversation.
77
+ deliveredFinalAnswers.delete(scope);
78
+ deliveredFinalAnswers.set(scope, { fingerprint: textFingerprint(text), createdAt: at });
79
+ pruneDeliveredFinalAnswers(at);
80
+ }
81
+
82
+ /**
83
+ * Match only when the delivered assistant answer is still the trailing content-bearing message.
84
+ * A later user/tool-result message is new work and must reach the provider, even though every
85
+ * legitimate next turn necessarily contains such a message somewhere in its history.
86
+ */
87
+ export function hasRecordedTrailingDeliveredFinalAnswer(
88
+ parsed: OcxParsedRequest,
89
+ messages: readonly OcxMessage[],
90
+ ): boolean {
91
+ const scope = scopesByRequest.get(parsed);
92
+ if (!scope) return false;
93
+ pruneDeliveredFinalAnswers();
94
+ const record = deliveredFinalAnswers.get(scope);
95
+ if (!record) return false;
96
+ for (let i = messages.length - 1; i >= 0; i--) {
97
+ const message = messages[i];
98
+ if (message.role !== "assistant") return false;
99
+ const text = assistantText(message as OcxAssistantMessage);
100
+ if (text === undefined) {
101
+ if ((message as OcxAssistantMessage).content.some(part => part.type === "toolCall")) return false;
102
+ continue;
103
+ }
104
+ return textFingerprint(text) === record.fingerprint;
105
+ }
106
+ return false;
107
+ }
@@ -32,8 +32,6 @@ import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
32
32
  import { clearThreadAccountMap } from "../../codex/routing";
33
33
  import { primeCodexPoolQuotas } from "../../codex/auth-api";
34
34
  import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap";
35
- import { resolveCodexHomeDir } from "../../codex/home";
36
- import { scanStorage } from "../../storage/scanner";
37
35
  import { executeArchivedCleanup, listTrashEntries, pickWireCleanupTestHooks, previewArchivedCleanup, type CleanupMode, type RestoreErrorCode } from "../../storage/cleanup";
38
36
  import { runArchivedCleanupJob } from "../../storage/cleanup-job";
39
37
  import { getRestoreTrashTestStreamResponse, runRestoreTrashEntryJob } from "../../storage/restore-job";
@@ -343,20 +341,6 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
343
341
  }
344
342
  }
345
343
 
346
- if (url.pathname === "/api/storage" && req.method === "GET") {
347
- try {
348
- return jsonResponse(scanStorage());
349
- } catch {
350
- return jsonResponse({
351
- codexHome: resolveCodexHomeDir(),
352
- generatedAt: Date.now(),
353
- total: { bytes: 0, fileCount: 0 },
354
- buckets: [],
355
- error: "scan_failed",
356
- });
357
- }
358
- }
359
-
360
344
  if (url.pathname === "/api/storage/cleanup/preview" && req.method === "POST") {
361
345
  let body: { percent?: unknown };
362
346
  try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid_json" }, 400); }