@bitkyc08/opencodex 2.7.41 → 2.7.42

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 (80) hide show
  1. package/README.md +4 -0
  2. package/gui/dist/assets/index-Bl_VBGoI.js +65 -0
  3. package/gui/dist/assets/index-DfVGuN88.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/base.ts +6 -0
  7. package/src/adapters/kiro-constants.ts +6 -2
  8. package/src/adapters/kiro-retry.ts +175 -10
  9. package/src/adapters/kiro.ts +172 -85
  10. package/src/adapters/mimo-free.ts +1 -0
  11. package/src/adapters/openai-chat.ts +30 -4
  12. package/src/adapters/openai-responses.ts +90 -12
  13. package/src/bridge.ts +91 -43
  14. package/src/claude/desktop-3p-paths.ts +84 -0
  15. package/src/claude/desktop-3p.ts +29 -2
  16. package/src/cli/access.ts +108 -0
  17. package/src/cli/account-auth.ts +223 -0
  18. package/src/cli/account.ts +9 -1
  19. package/src/cli/agent.ts +184 -0
  20. package/src/cli/combo.ts +119 -0
  21. package/src/cli/config-command.ts +145 -0
  22. package/src/cli/debug.ts +20 -8
  23. package/src/cli/doctor.ts +45 -8
  24. package/src/cli/help.ts +65 -13
  25. package/src/cli/index.ts +108 -7
  26. package/src/cli/integrations.ts +142 -0
  27. package/src/cli/models-runtime.ts +212 -0
  28. package/src/cli/models.ts +9 -10
  29. package/src/cli/observe.ts +117 -0
  30. package/src/cli/provider-runtime.ts +152 -0
  31. package/src/cli/provider.ts +23 -1
  32. package/src/cli/runtime-api.ts +325 -0
  33. package/src/cli/star-prompt.ts +3 -3
  34. package/src/cli/status.ts +17 -0
  35. package/src/cli/system-command.ts +112 -0
  36. package/src/codex/auth-api.ts +3 -2
  37. package/src/codex/catalog/aggregation.ts +113 -18
  38. package/src/codex/catalog/provider-fetch.ts +24 -13
  39. package/src/codex/catalog/sync.ts +20 -8
  40. package/src/codex/catalog.ts +2 -1
  41. package/src/codex/refresh.ts +10 -3
  42. package/src/codex/routing.ts +21 -32
  43. package/src/codex/sync.ts +17 -0
  44. package/src/config.ts +48 -0
  45. package/src/generated/jawcode-model-metadata.ts +2 -1
  46. package/src/grok/inject.ts +184 -4
  47. package/src/grok/status.ts +33 -0
  48. package/src/lib/retry-after.ts +55 -0
  49. package/src/lib/windows-elevation.ts +627 -0
  50. package/src/providers/openai-sidecar.ts +46 -2
  51. package/src/providers/registry.ts +52 -0
  52. package/src/server/auth-cors.ts +6 -0
  53. package/src/server/chat-completions.ts +6 -1
  54. package/src/server/claude-messages.ts +20 -1
  55. package/src/server/images.ts +14 -7
  56. package/src/server/management/agent-settings-routes.ts +10 -4
  57. package/src/server/management/combo-routes.ts +0 -1
  58. package/src/server/management/config-routes.ts +0 -1
  59. package/src/server/management/logs-usage-routes.ts +94 -0
  60. package/src/server/management/model-routes.ts +0 -1
  61. package/src/server/management/oauth-account-routes.ts +0 -1
  62. package/src/server/management/provider-routes.ts +0 -1
  63. package/src/server/management/shared.ts +0 -1
  64. package/src/server/management/system-routes.ts +27 -15
  65. package/src/server/management-api.ts +0 -1
  66. package/src/server/memory-watchdog.ts +54 -10
  67. package/src/server/request-log-conversation.ts +168 -0
  68. package/src/server/request-log.ts +122 -2
  69. package/src/server/responses/core.ts +76 -13
  70. package/src/server/responses/passthrough-error.ts +38 -13
  71. package/src/server/startup-action-control.ts +266 -15
  72. package/src/service.ts +512 -3
  73. package/src/storage/cleanup.ts +1538 -0
  74. package/src/storage/scanner.ts +4 -1
  75. package/src/types.ts +16 -0
  76. package/src/update/job.ts +229 -25
  77. package/src/usage/log.ts +39 -0
  78. package/src/web-search/loop.ts +8 -1
  79. package/gui/dist/assets/index-B2J4t3te.css +0 -1
  80. package/gui/dist/assets/index-BmvM6wRb.js +0 -65
@@ -180,6 +180,19 @@ const THINKING_TOGGLE_MAP: Record<string, string> = {
180
180
  const OPENCODE_GO_THINKING_TOGGLE_MODELS = [
181
181
  "mimo-v2.5", "mimo-v2.5-pro", "mimo-v2-omni", "mimo-v2-pro", "glm-5", "glm-5.1",
182
182
  ];
183
+ /**
184
+ * Zhipu's domestic BigModel platform. Text families first, then the vision member: modalities are
185
+ * declared per model because `noVisionModels` means the opposite of "text only" here — it routes
186
+ * images through the proxy's vision sidecar (src/codex/catalog/provider-fetch.ts), a claim nobody
187
+ * has verified for BigModel-hosted GLM.
188
+ */
189
+ const ZHIPU_BIGMODEL_TEXT_MODELS = ["glm-4.6", "glm-4.7", "glm-4.7-flash", "glm-5", "glm-5.1"];
190
+ const ZHIPU_BIGMODEL_MODELS = [...ZHIPU_BIGMODEL_TEXT_MODELS, "glm-4.6v"];
191
+ const ZHIPU_BIGMODEL_INPUT_MODALITIES: Record<string, string[]> = {
192
+ ...Object.fromEntries(ZHIPU_BIGMODEL_TEXT_MODELS.map(id => [id, ["text"]])),
193
+ "glm-4.6v": ["text", "image"],
194
+ };
195
+ const ZHIPU_BIGMODEL_THINKING_TOGGLE_MODELS = ["glm-4.6", "glm-4.7", "glm-5", "glm-5.1"];
183
196
  const THINKING_BUDGET_EFFORTS = ["low", "medium", "high", "xhigh", "max"];
184
197
  const THINKING_BUDGET_MODELS = [
185
198
  "qwen3.5-397b", "qwen3.6-35b",
@@ -785,6 +798,45 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
785
798
  modelReasoningEfforts: Object.fromEntries(ZAI_GLM_52_MODELS.map(id => [id, ZAI_GLM_52_REASONING_EFFORTS])),
786
799
  preserveReasoningContentModels: ZAI_GLM_52_MODELS,
787
800
  },
801
+ // Zhipu's domestic BigModel platform: OpenAI-compatible pay-as-you-go on open.bigmodel.cn — a
802
+ // different host and billing product from the `zai` coding-plan subscription above.
803
+ // The id is deliberately NOT `glm` or `glm-cn`: both are already bound in FREE_PROVIDER_DIRECTORY
804
+ // (to api.z.ai and to the BigModel *coding* path), and routedProviderConfig() canonicalizes a
805
+ // saved provider onto the registry baseUrl — reusing either id would silently retarget an
806
+ // existing config's endpoint and send its API key to another host.
807
+ // Evidence: docs.bigmodel.cn/api-reference (OpenAI-compatible chat completions),
808
+ // docs.bigmodel.cn/cn/guide/models/text/glm-4.6 (thinking: {type: enabled|disabled}).
809
+ // Originally proposed in #536 by @Lucinegogo.
810
+ {
811
+ id: "zhipu-bigmodel",
812
+ label: "Zhipu AI — BigModel",
813
+ baseUrl: "https://open.bigmodel.cn/api/paas/v4",
814
+ adapter: "openai-chat",
815
+ authKind: "key",
816
+ dashboardUrl: "https://bigmodel.cn/console/usercenter/apikeys",
817
+ defaultModel: "glm-4.6",
818
+ models: ZHIPU_BIGMODEL_MODELS,
819
+ // The GLM families here are the same ones the `zai` metadata bundle already describes, so the
820
+ // bundle owns context windows and modalities for the whole list instead of a hand-copied table.
821
+ jawcodeBundle: "zai",
822
+ // Declared explicitly for the default model so its window survives a bundle-lookup miss:
823
+ // without it, catalog normalization falls back to a generic 128k and compacts ~76,800 early.
824
+ modelContextWindows: { "glm-4.6": 204_800 },
825
+ modelInputModalities: ZHIPU_BIGMODEL_INPUT_MODALITIES,
826
+ // GLM exposes a binary thinking knob, not an effort ladder: the adapter emits
827
+ // `thinking: {type}` for these ids and would otherwise send a rejected reasoning_effort.
828
+ thinkingToggleModels: ZHIPU_BIGMODEL_THINKING_TOGGLE_MODELS,
829
+ modelReasoningEfforts: Object.fromEntries(
830
+ ZHIPU_BIGMODEL_THINKING_TOGGLE_MODELS.map(id => [id, THINKING_TOGGLE_EFFORTS]),
831
+ ),
832
+ modelReasoningEffortMap: Object.fromEntries(
833
+ ZHIPU_BIGMODEL_THINKING_TOGGLE_MODELS.map(id => [id, THINKING_TOGGLE_MAP]),
834
+ ),
835
+ preserveReasoningContentModels: ZHIPU_BIGMODEL_THINKING_TOGGLE_MODELS,
836
+ // No liveModels: GET /api/paas/v4/models has not been observed to answer on this host, and a
837
+ // false live claim yields an empty picker at runtime. Flip it on once someone verifies it.
838
+ note: "Domestic BigModel pay-as-you-go endpoint (open.bigmodel.cn)",
839
+ },
788
840
  { id: "nanogpt", label: "NanoGPT", baseUrl: "https://nano-gpt.com/api/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://nano-gpt.com/api" },
789
841
  { id: "synthetic", label: "Synthetic", baseUrl: "https://api.synthetic.new/openai/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://synthetic.new" },
790
842
  // SiliconFlow publishes an OpenAI-compatible chat endpoint and a dynamic model catalog. Do not
@@ -8,6 +8,7 @@ import {
8
8
  positiveIntegerRecordConfigError,
9
9
  providerBaseUrlConfigError,
10
10
  providerHeadersConfigError,
11
+ reasoningSummaryDeliveryRecordConfigError,
11
12
  } from "../config";
12
13
  import { providerDestinationConfigError } from "../lib/destination-policy";
13
14
  import { getProviderRegistryEntry, providerCodexAccountMode } from "../providers/registry";
@@ -235,6 +236,11 @@ export function providerManagementConfigError(name: unknown, provider: unknown):
235
236
  if (maxInputError) return `provider ${name} ${maxInputError}`;
236
237
  const reasoningSummariesError = booleanRecordConfigError(raw.modelSupportsReasoningSummaries, "modelSupportsReasoningSummaries");
237
238
  if (reasoningSummariesError) return `provider ${name} ${reasoningSummariesError}`;
239
+ const reasoningSummaryDeliveryError = reasoningSummaryDeliveryRecordConfigError(
240
+ raw.modelReasoningSummaryDelivery,
241
+ raw.modelSupportsReasoningSummaries,
242
+ );
243
+ if (reasoningSummaryDeliveryError) return `provider ${name} ${reasoningSummaryDeliveryError}`;
238
244
  const modelAdaptersError = modelAdapterRecordConfigError(raw.modelAdapters, "modelAdapters", name, typed);
239
245
  if (modelAdaptersError) return `provider ${name} ${modelAdaptersError}`;
240
246
  const defaultMaxOutputError = positiveIntegerConfigError(raw.defaultMaxOutputTokens, "defaultMaxOutputTokens");
@@ -16,6 +16,7 @@ import {
16
16
  } from "../chat/outbound";
17
17
  import { classifyError, CYBER_POLICY_ERROR_CODE, isCyberPolicyCode } from "../lib/errors";
18
18
  import { redactSecretString } from "../lib/redact";
19
+ import { resolveClientRetryAfter } from "../lib/retry-after";
19
20
  import { estimateTokens } from "../lib/token-estimate";
20
21
  import { routeModel } from "../router";
21
22
  import { resolveWireProtocolOverride } from "./adapter-resolve";
@@ -180,7 +181,11 @@ export async function handleChatCompletions(
180
181
  if (text) message = `upstream error (${upstream.status}): ${redactSecretString(text).slice(0, 400)}`;
181
182
  }
182
183
  } catch { /* keep fallback */ }
183
- const retryAfter = upstream.headers.get("retry-after");
184
+ const retryAfter = resolveClientRetryAfter({
185
+ status: upstream.status,
186
+ message,
187
+ upstreamRetryAfter: upstream.headers.get("retry-after"),
188
+ });
184
189
  const classified = classifyError(
185
190
  upstream.status,
186
191
  upstreamType
@@ -15,6 +15,7 @@ import { recordDesktopRequest } from "../claude/desktop-health";
15
15
  import { stripOneMillionMarker } from "../claude/context-windows";
16
16
  import { captureClaudeInbound } from "../claude/inbound-debug";
17
17
  import { isTransientUpstreamStatus } from "../lib/upstream-retry";
18
+ import { resolveClientRetryAfter } from "../lib/retry-after";
18
19
  import {
19
20
  anthropicErrorBody,
20
21
  anthropicErrorResponse,
@@ -29,6 +30,7 @@ import { resolveWireProtocolOverride } from "./adapter-resolve";
29
30
  import type { OcxConfig } from "../types";
30
31
  import { readJsonRequestBody } from "./request-decompress";
31
32
  import { addFinalRequestLog, httpStatusForTerminalStatus, recordFirstOutput, type RequestLogContext, type RequestLogEntry } from "./request-log";
33
+ import { conversationIdFromClaudeMetadata } from "./request-log-conversation";
32
34
  import { responseWithDeferredRequestLog } from "./relay";
33
35
  import { handleResponses } from "./responses";
34
36
 
@@ -555,6 +557,13 @@ export async function handleClaudeMessages(
555
557
  logCtx.surface = "claude-desktop";
556
558
  recordDesktopRequest();
557
559
  }
560
+ // Correlate before native passthrough so Anthropic-credential turns still filter/total (#330 / #522).
561
+ if (isRec(anthropicBody)) {
562
+ const claudeConversationId = conversationIdFromClaudeMetadata(
563
+ isRec(anthropicBody.metadata) ? anthropicBody.metadata : undefined,
564
+ );
565
+ if (claudeConversationId) logCtx.conversationId = claudeConversationId;
566
+ }
558
567
  if (isRec(anthropicBody) && wantsNativePassthrough(req, config, anthropicBody.model)) {
559
568
  return await anthropicNativePassthrough(req, config, logCtx, logIds, anthropicBody, "/v1/messages");
560
569
  }
@@ -689,12 +698,22 @@ export async function handleClaudeMessages(
689
698
  if (text) message = `upstream error (${response.status}): ${text.slice(0, 400)}`;
690
699
  }
691
700
  } catch { /* keep fallback message */ }
692
- const retryAfter = response.headers.get("retry-after");
701
+ const upstreamRetryAfter = response.headers.get("retry-after");
702
+ const retryAfter = resolveClientRetryAfter({
703
+ status: response.status,
704
+ message,
705
+ upstreamRetryAfter,
706
+ })
707
+ // Instant-retry "0" is a valid client directive but rejected by cooldown parsers.
708
+ // Preserve it so it still wins over the transient "2" fallback (claude-529 mapping).
709
+ ?? (upstreamRetryAfter?.trim() === "0" ? "0" : undefined);
693
710
  // Transient upstream 5xx (already retried pre-stream, 010): reclassify as Anthropic
694
711
  // 529 overloaded_error so the Claude Code client applies its built-in backoff retry
695
712
  // instead of dying on a fatal api_error (260716 sol-builder incident). The request
696
713
  // log keeps the upstream status (captured in the deferred-log closure before this
697
714
  // rewrite): log = upstream truth, client = retry signal.
715
+ // Retryable 429s also get Retry-After (#507) so Codex-shaped clients and Claude Code
716
+ // share a backoff hint when the upstream omitted the header.
698
717
  const transient = isTransientUpstreamStatus(response.status);
699
718
  const outStatus = transient ? 529 : response.status;
700
719
  const out = new Response(JSON.stringify(anthropicErrorBody(outStatus, message)), {
@@ -7,7 +7,8 @@
7
7
  * without a route the tool died on the /v1/* JSON-404 guard. Only an OpenAI-family upstream
8
8
  * can serve these endpoints — routed providers (Cursor, Kiro, Gemini, …) have no image
9
9
  * generation surface — so the handler relays the body verbatim to the ChatGPT forward
10
- * provider (or an OpenAI API-key provider) and passes the response through untouched:
10
+ * provider, an OpenAI API-key provider, or an explicitly selected compatible custom provider and
11
+ * passes the response through untouched:
11
12
  * codex's images client parses `{created, data:[{b64_json}]}` strictly and Debug-prints
12
13
  * error bodies into the model-visible failure, so upstream errors must stay legible.
13
14
  */
@@ -23,7 +24,7 @@ import { formatCodexProviderForLog } from "../codex/routing";
23
24
  import { signalWithTimeout } from "../lib/abort";
24
25
  import { sidecarEnter } from "../lib/sidecar-tracker";
25
26
  import type { OcxConfig } from "../types";
26
- import { resolveFirstUsableOpenAiSidecar, selectOpenAiImagesProvider } from "../providers/openai-sidecar";
27
+ import { resolveFirstUsableOpenAiSidecar, selectImagesProvider } from "../providers/openai-sidecar";
27
28
  import { readJsonRequestBody } from "./request-decompress";
28
29
  import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "./auth-cors";
29
30
  import type { RequestLogContext } from "./request-log";
@@ -47,10 +48,17 @@ export async function handleImages(
47
48
  endpoint: ImagesEndpoint,
48
49
  logCtx: RequestLogContext,
49
50
  ): Promise<Response> {
50
- try { validateForwardAdmissionCredential(req.headers, config); }
51
- catch (err) {
52
- if (err instanceof ForwardAdmissionCredentialError) return formatErrorResponse(401, "authentication_error", err.message);
53
- throw err;
51
+ const candidates = selectImagesProvider(config);
52
+ if (candidates.error) {
53
+ return formatErrorResponse(400, "invalid_request_error", candidates.error);
54
+ }
55
+ const explicitKeyedProvider = config.images?.provider !== undefined && candidates.keyed !== undefined;
56
+ if (!explicitKeyedProvider) {
57
+ try { validateForwardAdmissionCredential(req.headers, config); }
58
+ catch (err) {
59
+ if (err instanceof ForwardAdmissionCredentialError) return formatErrorResponse(401, "authentication_error", err.message);
60
+ throw err;
61
+ }
54
62
  }
55
63
  let body: unknown;
56
64
  try {
@@ -61,7 +69,6 @@ export async function handleImages(
61
69
  const model = (body as { model?: unknown } | null)?.model;
62
70
  if (typeof model === "string" && model) logCtx.model = model;
63
71
 
64
- const candidates = selectOpenAiImagesProvider(config);
65
72
  if (candidates.forwardCandidates.length === 0 && !candidates.keyed) {
66
73
  // 400, not 5xx: codex retries every 5xx up to 5 total attempts, and this is a permanent
67
74
  // configuration state that must surface on the first attempt.
@@ -33,7 +33,6 @@ 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
35
  import { resolveCodexHomeDir } from "../../codex/home";
36
- import { scanStorage } from "../../storage/scanner";
37
36
  import { readUsageEntries } from "../../usage/log";
38
37
  import { getUsageDebugLogEntries } from "../../usage/debug";
39
38
  import { parseRange, parseUsageSurface, summarizeUsage } from "../../usage/summary";
@@ -543,16 +542,22 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
543
542
  const { readFileSync: readFile, existsSync } = await import("node:fs");
544
543
  const { createHash } = await import("node:crypto");
545
544
  const { join } = await import("node:path");
546
- const { homedir } = await import("node:os");
547
- const libraryPath = process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR?.trim()
548
- || join(homedir(), "Library", "Application Support", "Claude-3p", "configLibrary");
545
+ const { resolveDesktop3pConfigLibraryPath } = await import("../../claude/desktop-3p");
546
+ const libraryPath = resolveDesktop3pConfigLibraryPath();
549
547
  const metaPath = join(libraryPath, "_meta.json");
550
548
  let onDiskFingerprint: string | null = null;
551
549
  let configPath: string | null = null;
550
+ // Desktop serves ONLY the profile named by _meta.json's appliedId, so an
551
+ // opencodex entry that merely EXISTS does not mean Desktop is using it.
552
+ // null = undeterminable (no metadata / unreadable / no appliedId).
553
+ let activeProfile: boolean | null = null;
552
554
  if (existsSync(metaPath)) {
553
555
  try {
554
556
  const meta = JSON.parse(readFile(metaPath, "utf8"));
555
557
  const entry = Array.isArray(meta.entries) ? meta.entries.find((e: { name?: string }) => e?.name === "opencodex") : undefined;
558
+ const appliedId = typeof meta.appliedId === "string" ? meta.appliedId : null;
559
+ // A readable appliedId with no opencodex entry is a KNOWN false, not unknown.
560
+ activeProfile = appliedId === null ? null : (entry?.id ? appliedId === entry.id : false);
556
561
  if (entry?.id) {
557
562
  configPath = join(libraryPath, `${entry.id}.json`);
558
563
  if (existsSync(configPath)) {
@@ -574,6 +579,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
574
579
  onDiskFingerprint,
575
580
  configPath,
576
581
  stale,
582
+ activeProfile,
577
583
  health,
578
584
  });
579
585
  } catch (error) {
@@ -33,7 +33,6 @@ 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
35
  import { resolveCodexHomeDir } from "../../codex/home";
36
- import { scanStorage } from "../../storage/scanner";
37
36
  import { readUsageEntries } from "../../usage/log";
38
37
  import { getUsageDebugLogEntries } from "../../usage/debug";
39
38
  import { parseRange, parseUsageSurface, summarizeUsage } from "../../usage/summary";
@@ -34,7 +34,6 @@ import { clearThreadAccountMap } from "../../codex/routing";
34
34
  import { primeCodexPoolQuotas } from "../../codex/auth-api";
35
35
  import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap";
36
36
  import { resolveCodexHomeDir } from "../../codex/home";
37
- import { scanStorage } from "../../storage/scanner";
38
37
  import { readUsageEntries } from "../../usage/log";
39
38
  import { getUsageDebugLogEntries } from "../../usage/debug";
40
39
  import { parseRange, parseUsageSurface, summarizeUsage } from "../../usage/summary";
@@ -34,6 +34,7 @@ import { primeCodexPoolQuotas } from "../../codex/auth-api";
34
34
  import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap";
35
35
  import { resolveCodexHomeDir } from "../../codex/home";
36
36
  import { scanStorage } from "../../storage/scanner";
37
+ import { executeArchivedCleanup, pickWireCleanupTestHooks, previewArchivedCleanup, type CleanupMode } from "../../storage/cleanup";
37
38
  import {
38
39
  currentUsageLogRevision,
39
40
  readUsageSnapshotForManagement,
@@ -232,5 +233,98 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
232
233
  });
233
234
  }
234
235
  }
236
+
237
+ if (url.pathname === "/api/storage/cleanup/preview" && req.method === "POST") {
238
+ let body: { percent?: unknown };
239
+ try { body = await req.json(); } catch { return jsonResponse({ error: "invalid_json" }, 400); }
240
+ const percent = typeof body?.percent === "number" ? body.percent : Number.NaN;
241
+ if (!Number.isFinite(percent) || percent < 0 || percent > 100) {
242
+ return jsonResponse({ error: "invalid_percent" }, 400);
243
+ }
244
+ const preview = previewArchivedCleanup(percent);
245
+ // Omit absolute host paths (codexHome / absPath) from the wire response.
246
+ return jsonResponse({
247
+ percent: preview.percent,
248
+ count: preview.count,
249
+ bytes: preview.bytes,
250
+ digest: preview.digest,
251
+ // Dashboard only lists a handful; count/bytes/digest already bind the full set.
252
+ candidates: preview.candidates.slice(0, 50).map(({ relPath, bytes, mtimeMs, physicalRelPaths }) => ({
253
+ relPath,
254
+ bytes,
255
+ mtimeMs,
256
+ physicalRelPaths,
257
+ })),
258
+ });
259
+ }
260
+
261
+ if (url.pathname === "/api/storage/cleanup" && req.method === "POST") {
262
+ let body: { percent?: unknown; mode?: unknown; digest?: unknown; _test?: unknown };
263
+ try { body = await req.json(); } catch { return jsonResponse({ error: "invalid_json" }, 400); }
264
+ const percent = typeof body?.percent === "number" ? body.percent : Number.NaN;
265
+ if (!Number.isFinite(percent) || percent < 0 || percent > 100) {
266
+ return jsonResponse({ error: "invalid_percent" }, 400);
267
+ }
268
+ const mode = body?.mode;
269
+ if (mode !== "quarantine" && mode !== "permanent") {
270
+ return jsonResponse({ error: "invalid_mode" }, 400);
271
+ }
272
+ const digest = typeof body?.digest === "string" ? body.digest : "";
273
+ const testHooks =
274
+ process.env.OPENCODEX_CLEANUP_TEST_HOOKS === "1" &&
275
+ body &&
276
+ typeof body === "object" &&
277
+ "_test" in body
278
+ ? pickWireCleanupTestHooks(body._test)
279
+ : undefined;
280
+ try {
281
+ const result = executeArchivedCleanup({
282
+ percent,
283
+ mode: mode as CleanupMode,
284
+ digest,
285
+ ...(testHooks ? { _test: testHooks } : {}),
286
+ });
287
+ if (!result.ok) {
288
+ const status =
289
+ result.error === "codex_busy" || result.error === "stale_preview" || result.error === "referenced_history"
290
+ ? 409
291
+ : result.error === "invalid_mode" || result.error === "invalid_digest"
292
+ ? 400
293
+ : 500;
294
+ const messages: Record<string, string> = {
295
+ codex_busy: "Codex is using state.sqlite — try again after quitting Codex.",
296
+ stale_preview: "Archived files changed since preview — run Preview again.",
297
+ referenced_history: "Selected archives are still referenced by forked or paginated history.",
298
+ invalid_digest: "Preview digest is missing or invalid.",
299
+ invalid_mode: "mode must be quarantine or permanent.",
300
+ fs_failed: "Filesystem cleanup failed. Some changes may already be applied — check CODEX_HOME/.trash and any recovery path in the response.",
301
+ db_reconcile_failed: "Could not update Codex state database.",
302
+ cleanup_failed: "Cleanup failed.",
303
+ };
304
+ return jsonResponse({
305
+ ok: false,
306
+ error: result.error ?? "cleanup_failed",
307
+ message: messages[result.error ?? ""] ?? messages.cleanup_failed,
308
+ ...(result.trashDir ? { trashDir: result.trashDir } : {}),
309
+ }, status);
310
+ }
311
+ return jsonResponse({
312
+ ok: true,
313
+ mode: result.mode,
314
+ percent: result.percent,
315
+ count: result.count,
316
+ bytes: result.bytes,
317
+ ...(result.trashDir ? { trashDir: result.trashDir } : {}),
318
+ removedPaths: result.removedPaths,
319
+ });
320
+ } catch {
321
+ return jsonResponse({
322
+ ok: false,
323
+ error: "cleanup_failed",
324
+ message: "Cleanup failed.",
325
+ }, 500);
326
+ }
327
+ }
328
+
235
329
  return null;
236
330
  }
@@ -35,7 +35,6 @@ import { clearThreadAccountMap } from "../../codex/routing";
35
35
  import { primeCodexPoolQuotas } from "../../codex/auth-api";
36
36
  import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap";
37
37
  import { resolveCodexHomeDir } from "../../codex/home";
38
- import { scanStorage } from "../../storage/scanner";
39
38
  import { readUsageEntries } from "../../usage/log";
40
39
  import { getUsageDebugLogEntries } from "../../usage/debug";
41
40
  import { parseRange, parseUsageSurface, summarizeUsage } from "../../usage/summary";
@@ -33,7 +33,6 @@ 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
35
  import { resolveCodexHomeDir } from "../../codex/home";
36
- import { scanStorage } from "../../storage/scanner";
37
36
  import { readUsageEntries } from "../../usage/log";
38
37
  import { getUsageDebugLogEntries } from "../../usage/debug";
39
38
  import { parseRange, parseUsageSurface, summarizeUsage } from "../../usage/summary";
@@ -34,7 +34,6 @@ import { primeCodexPoolQuotas } from "../../codex/auth-api";
34
34
  import { getProviderDiscoveryStatus } from "../../codex/model-cache";
35
35
  import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap";
36
36
  import { resolveCodexHomeDir } from "../../codex/home";
37
- import { scanStorage } from "../../storage/scanner";
38
37
  import { readUsageEntries } from "../../usage/log";
39
38
  import { getUsageDebugLogEntries } from "../../usage/debug";
40
39
  import { parseRange, parseUsageSurface, summarizeUsage } from "../../usage/summary";
@@ -33,7 +33,6 @@ 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
35
  import { resolveCodexHomeDir } from "../../codex/home";
36
- import { scanStorage } from "../../storage/scanner";
37
36
  import { readUsageEntries } from "../../usage/log";
38
37
  import { getUsageDebugLogEntries } from "../../usage/debug";
39
38
  import { parseRange, parseUsageSurface, summarizeUsage } from "../../usage/summary";
@@ -7,15 +7,16 @@
7
7
  * unauthenticated /healthz surface.
8
8
  *
9
9
  * The payload is scalar-only (numbers, enum strings): no paths, no tokens, no
10
- * account identifiers. `jscHeap` (bun:jsc heapStats) is the js-vs-native
11
- * discriminator: a flat JS heap under a growing RSS points at native runtime
12
- * memory (the #314 shape), not an app-level JS leak. `responseState` attributes
13
- * JS-heap growth further: it is the proxy's previous_response_id continuation
14
- * store, so a growing responseState.totalBytes under a growing heap points the
15
- * finger at conversation retention rather than the runtime allocator.
10
+ * account identifiers. `external` and `arrayBuffers` keep Windows diagnostics
11
+ * honest when RSS/working-set counters under-report committed retention.
12
+ * `jscHeap` (bun:jsc heapStats) is useful context, but on Bun 1.3.14 it is not a
13
+ * standalone leak discriminator. `responseState` attributes growth further: it
14
+ * is the proxy's previous_response_id continuation store, so a growing
15
+ * responseState.totalBytes under rising observed memory points at conversation
16
+ * retention rather than the runtime allocator.
16
17
  */
17
18
  import { decideEagerRelay } from "../../lib/bun-stream-caps";
18
- import { getActiveMemoryWatchdog } from "../memory-watchdog";
19
+ import { getActiveMemoryWatchdog, observedMemoryCounter } from "../memory-watchdog";
19
20
  import { responseStateMetrics } from "../../responses/state";
20
21
  import { jsonResponse } from "../auth-cors";
21
22
  import type { ManagementContext } from "./context";
@@ -38,15 +39,22 @@ export async function handleSystemRoutes(ctx: ManagementContext): Promise<Respon
38
39
  } catch {
39
40
  /* non-Bun tooling or unavailable introspection — omit the discriminator */
40
41
  }
41
- const watchdogInstance = getActiveMemoryWatchdog();
42
+ const watchdogInstance = getActiveMemoryWatchdog();
43
+ const observed = observedMemoryCounter({
44
+ rss: usage.rss,
45
+ external: usage.external,
46
+ arrayBuffers: usage.arrayBuffers,
47
+ });
42
48
  const watchdog = watchdogInstance
43
49
  ? (() => {
44
50
  const snap = watchdogInstance.snapshot();
45
- return {
46
- warnThresholdBytes: snap.warnThresholdBytes,
47
- lastWarnAt: snap.lastWarnAt,
48
- samples: snap.samples.slice(-ENDPOINT_SAMPLE_LIMIT),
49
- };
51
+ return {
52
+ warnThresholdBytes: snap.warnThresholdBytes,
53
+ lastWarnAt: snap.lastWarnAt,
54
+ observedBytes: snap.observedBytes,
55
+ observedMetric: snap.observedMetric,
56
+ samples: snap.samples.slice(-ENDPOINT_SAMPLE_LIMIT),
57
+ };
50
58
  })()
51
59
  : null;
52
60
  const streamMode = config.streamMode ?? "auto";
@@ -58,8 +66,12 @@ export async function handleSystemRoutes(ctx: ManagementContext): Promise<Respon
58
66
  uptimeSeconds: process.uptime(),
59
67
  rss: usage.rss,
60
68
  heapUsed: usage.heapUsed,
61
- heapTotal: usage.heapTotal,
62
- jscHeap,
69
+ heapTotal: usage.heapTotal,
70
+ external: usage.external,
71
+ arrayBuffers: usage.arrayBuffers,
72
+ observedBytes: observed.observedBytes,
73
+ observedMetric: observed.observedMetric,
74
+ jscHeap,
63
75
  responseState: responseStateMetrics(),
64
76
  streamMode,
65
77
  eagerRelay: process.platform === "win32" ? decideEagerRelay(streamMode) : null,
@@ -33,7 +33,6 @@ 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
35
  import { resolveCodexHomeDir } from "../codex/home";
36
- import { scanStorage } from "../storage/scanner";
37
36
  import { readUsageEntries } from "../usage/log";
38
37
  import { getUsageDebugLogEntries } from "../usage/debug";
39
38
  import { parseRange, parseUsageSurface, summarizeUsage } from "../usage/summary";
@@ -1,9 +1,9 @@
1
1
  /**
2
- * RSS memory watchdog (#314 WP3) — warn-only observability for the Windows
2
+ * Memory watchdog (#314 WP3 / #509) — warn-only observability for the Windows
3
3
  * native-memory growth reported upstream (Bun fetch buffers / socket handles).
4
4
  *
5
5
  * Samples process.memoryUsage() on an unref'd interval into a bounded ring and
6
- * logs ONE rate-limited warning when RSS crosses the threshold. It never
6
+ * logs ONE rate-limited warning when observed memory crosses the threshold. It never
7
7
  * restarts anything (threshold auto-restart is deliberately deferred; the
8
8
  * service managers' crash-respawn already covers hard failures). The active
9
9
  * instance is a module-level singleton so the management API can expose the
@@ -13,7 +13,7 @@
13
13
  * paths, hostnames, or tokens.
14
14
  */
15
15
 
16
- export type MemorySample = {
16
+ export type MemorySampleBase = {
17
17
  /** Epoch ms. */
18
18
  at: number;
19
19
  /** Resident set size in bytes. */
@@ -22,12 +22,27 @@ export type MemorySample = {
22
22
  heapUsed: number;
23
23
  /** JS heap total in bytes. */
24
24
  heapTotal: number;
25
+ /** External/native memory tracked by process.memoryUsage(). */
26
+ external: number;
27
+ /** ArrayBuffer memory tracked by process.memoryUsage(). */
28
+ arrayBuffers: number;
25
29
  };
26
30
 
31
+ export type MemorySample = MemorySampleBase & {
32
+ /** Largest observed memory counter used for thresholding. */
33
+ observedBytes: number;
34
+ /** Counter that produced observedBytes. */
35
+ observedMetric: MemoryMetric;
36
+ };
37
+
38
+ export type MemoryMetric = "rss" | "external" | "arrayBuffers";
39
+
27
40
  export type MemoryWatchdogState = {
28
41
  samples: MemorySample[];
29
42
  warnThresholdBytes: number;
30
43
  lastWarnAt: number | null;
44
+ observedBytes: number;
45
+ observedMetric: MemoryMetric;
31
46
  };
32
47
 
33
48
  export type MemoryWatchdog = {
@@ -43,6 +58,19 @@ const DOCS_URL = "https://opencodex.me/troubleshooting/windows-memory/";
43
58
 
44
59
  let active: MemoryWatchdog | null = null;
45
60
 
61
+ export function observedMemoryCounter(sample: Pick<MemorySampleBase, "rss" | "external" | "arrayBuffers">): {
62
+ observedBytes: number;
63
+ observedMetric: MemoryMetric;
64
+ } {
65
+ const values: Array<{ metric: MemoryMetric; bytes: number }> = [
66
+ { metric: "rss", bytes: sample.rss },
67
+ { metric: "external", bytes: sample.external },
68
+ { metric: "arrayBuffers", bytes: sample.arrayBuffers },
69
+ ];
70
+ const best = values.reduce((current, next) => next.bytes > current.bytes ? next : current, values[0]);
71
+ return { observedBytes: best.bytes, observedMetric: best.metric };
72
+ }
73
+
46
74
  /** The running watchdog, if any — read by /api/system/memory. */
47
75
  export function getActiveMemoryWatchdog(): MemoryWatchdog | null {
48
76
  return active;
@@ -50,7 +78,19 @@ export function getActiveMemoryWatchdog(): MemoryWatchdog | null {
50
78
 
51
79
  function defaultSample(now: () => number): MemorySample {
52
80
  const usage = process.memoryUsage();
53
- return { at: now(), rss: usage.rss, heapUsed: usage.heapUsed, heapTotal: usage.heapTotal };
81
+ const base = {
82
+ at: now(),
83
+ rss: usage.rss,
84
+ heapUsed: usage.heapUsed,
85
+ heapTotal: usage.heapTotal,
86
+ external: usage.external,
87
+ arrayBuffers: usage.arrayBuffers,
88
+ };
89
+ return { ...base, ...observedMemoryCounter(base) };
90
+ }
91
+
92
+ function normalizeSample(sample: MemorySampleBase): MemorySample {
93
+ return { ...sample, ...observedMemoryCounter(sample) };
54
94
  }
55
95
 
56
96
  /**
@@ -64,7 +104,7 @@ export function startMemoryWatchdog(opts?: {
64
104
  warnThresholdBytes?: number;
65
105
  ringSize?: number;
66
106
  now?: () => number;
67
- sample?: () => MemorySample;
107
+ sample?: () => MemorySampleBase;
68
108
  warn?: (msg: string) => void;
69
109
  }): MemoryWatchdog {
70
110
  active?.stop();
@@ -77,21 +117,25 @@ export function startMemoryWatchdog(opts?: {
77
117
 
78
118
  const samples: MemorySample[] = [];
79
119
  let lastWarnAt: number | null = null;
120
+ let observedBytes = 0;
121
+ let observedMetric: MemoryMetric = "rss";
80
122
 
81
123
  const tick = () => {
82
124
  let s: MemorySample;
83
125
  try {
84
- s = sample();
126
+ s = normalizeSample(sample());
85
127
  } catch {
86
128
  return; // sampling must never break the server
87
129
  }
88
130
  samples.push(s);
89
131
  if (samples.length > ringSize) samples.splice(0, samples.length - ringSize);
90
- if (s.rss >= warnThresholdBytes && (lastWarnAt === null || now() - lastWarnAt >= WARN_INTERVAL_MS)) {
132
+ observedBytes = s.observedBytes;
133
+ observedMetric = s.observedMetric;
134
+ if (s.observedBytes >= warnThresholdBytes && (lastWarnAt === null || now() - lastWarnAt >= WARN_INTERVAL_MS)) {
91
135
  lastWarnAt = now();
92
- const rssMb = Math.round(s.rss / (1024 * 1024));
136
+ const observedMb = Math.round(s.observedBytes / (1024 * 1024));
93
137
  const thresholdMb = Math.round(warnThresholdBytes / (1024 * 1024));
94
- warn(`⚠️ opencodex RSS ${rssMb}MB exceeds the ${thresholdMb}MB watch threshold. On Windows this is usually the upstream Bun runtime memory issue — see ${DOCS_URL}`);
138
+ warn(`⚠️ opencodex observed memory ${observedMb}MB (${s.observedMetric}) exceeds the ${thresholdMb}MB watch threshold. On Windows this is usually the upstream Bun runtime memory issue — see ${DOCS_URL}`);
95
139
  }
96
140
  };
97
141
 
@@ -104,7 +148,7 @@ export function startMemoryWatchdog(opts?: {
104
148
  if (active === instance) active = null;
105
149
  },
106
150
  snapshot() {
107
- return { samples: [...samples], warnThresholdBytes, lastWarnAt };
151
+ return { samples: [...samples], warnThresholdBytes, lastWarnAt, observedBytes, observedMetric };
108
152
  },
109
153
  };
110
154
  active = instance;