@bitkyc08/opencodex 2.42.0 → 2.43.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 (246) hide show
  1. package/AGENTS_INSTALL.md +2 -2
  2. package/README.md +31 -0
  3. package/bin/ocx.mjs +10 -1
  4. package/gui/dist/assets/index-DS1NE4Jn.css +1 -0
  5. package/gui/dist/assets/index-Djowl68T.js +112 -0
  6. package/gui/dist/index.html +2 -2
  7. package/package.json +1 -1
  8. package/src/adapters/anthropic-image-codec.ts +304 -0
  9. package/src/adapters/anthropic-image-normalize.ts +8 -298
  10. package/src/adapters/anthropic.ts +30 -7
  11. package/src/adapters/command-code.ts +7 -5
  12. package/src/adapters/cursor/desktop-executor-contract.ts +15 -0
  13. package/src/adapters/cursor/images.ts +36 -6
  14. package/src/adapters/cursor/live-transport.ts +7 -2
  15. package/src/adapters/cursor/native-exec-desktop.ts +2 -15
  16. package/src/adapters/cursor/protobuf-request.ts +13 -3
  17. package/src/adapters/cursor/tool-definitions.ts +5 -670
  18. package/src/adapters/cursor/tool-guidance.ts +236 -0
  19. package/src/adapters/cursor/tool-naming.ts +252 -0
  20. package/src/adapters/cursor/tool-schemas.ts +195 -0
  21. package/src/adapters/cursor/types.ts +6 -3
  22. package/src/adapters/exec-tool-result-normalize.ts +1 -1
  23. package/src/adapters/google-errors.ts +9 -1
  24. package/src/adapters/google.ts +1 -0
  25. package/src/adapters/kiro-calibration.ts +181 -0
  26. package/src/adapters/kiro.ts +135 -3
  27. package/src/adapters/openai-responses.ts +216 -26
  28. package/src/adapters/responses-code-mode.ts +59 -0
  29. package/src/adapters/tool-catalog-nudge.ts +1 -1
  30. package/src/adapters/xai-schema-analysis.ts +86 -0
  31. package/src/adapters/xai-tool-schema.ts +2 -87
  32. package/src/adapters/xai-web-search.ts +1 -1
  33. package/src/bridge.ts +22 -10
  34. package/src/chat/inbound.ts +11 -3
  35. package/src/claude/inbound-content-options.ts +60 -0
  36. package/src/claude/inbound-model-options.ts +142 -0
  37. package/src/claude/inbound-records.ts +7 -0
  38. package/src/claude/inbound.ts +10 -202
  39. package/src/claude/model-info.ts +45 -0
  40. package/src/cli/account-auth.ts +21 -6
  41. package/src/cli/capabilities.ts +13 -2
  42. package/src/cli/claude.ts +232 -39
  43. package/src/cli/config-command.ts +9 -1
  44. package/src/cli/dispatch.ts +5 -1
  45. package/src/cli/doctor.ts +10 -0
  46. package/src/cli/effort.ts +372 -0
  47. package/src/cli/export-command.ts +3 -9
  48. package/src/cli/help.ts +1 -0
  49. package/src/cli/index.ts +13 -0
  50. package/src/cli/init.ts +4 -0
  51. package/src/cli/model-selection-guidance.ts +30 -0
  52. package/src/cli/models-runtime.ts +3 -2
  53. package/src/cli/models.ts +8 -3
  54. package/src/cli/opencode.ts +4 -1
  55. package/src/cli/provider-runtime.ts +65 -0
  56. package/src/cli/provider.ts +8 -0
  57. package/src/cli/registry.ts +16 -2
  58. package/src/cli/runtime-api.ts +3 -1
  59. package/src/cli/star-prompt.ts +22 -6
  60. package/src/cli/status-probes.ts +168 -0
  61. package/src/cli/status.ts +5 -168
  62. package/src/clients/config-export/constants.ts +69 -0
  63. package/src/clients/config-export/contracts.ts +154 -0
  64. package/src/clients/config-export/dsh.ts +132 -0
  65. package/src/clients/config-export/fast-models.ts +29 -0
  66. package/src/clients/config-export/mcode.ts +83 -0
  67. package/src/clients/config-export/model-metadata.ts +108 -0
  68. package/src/clients/config-export/omp.ts +104 -0
  69. package/src/clients/config-export/zcode.ts +92 -0
  70. package/src/clients/config-export.ts +18 -710
  71. package/src/codex/account-lifecycle.ts +20 -3
  72. package/src/codex/account-usability.ts +2 -0
  73. package/src/codex/auth-api.ts +170 -23
  74. package/src/codex/auth-context.ts +328 -24
  75. package/src/codex/catalog/effort.ts +30 -4
  76. package/src/codex/catalog/metadata.ts +39 -9
  77. package/src/codex/catalog/native-models.ts +53 -12
  78. package/src/codex/catalog/parsing.ts +119 -5
  79. package/src/codex/catalog/provider-fetch.ts +150 -20
  80. package/src/codex/catalog/reserve.ts +52 -0
  81. package/src/codex/catalog/sync.ts +89 -16
  82. package/src/codex/convergence-types.ts +1 -0
  83. package/src/codex/convergence.ts +2 -0
  84. package/src/codex/data/upstream-models.json +1 -1
  85. package/src/codex/forward-transport-headers.ts +25 -0
  86. package/src/codex/inject.ts +3 -28
  87. package/src/codex/legacy-config-keys.ts +68 -0
  88. package/src/codex/log-guard/inspect-schema.ts +137 -0
  89. package/src/codex/log-guard/inspect.ts +2 -134
  90. package/src/codex/loopback-target.ts +54 -0
  91. package/src/codex/main-account-cache.ts +63 -1
  92. package/src/codex/main-account-hard-lock.ts +52 -0
  93. package/src/codex/main-account.ts +3 -1
  94. package/src/codex/management-convergence.ts +3 -0
  95. package/src/codex/model-entitlements.ts +54 -4
  96. package/src/codex/project-config-warnings.ts +92 -2
  97. package/src/codex/prompt-layers/encoding.ts +80 -0
  98. package/src/codex/prompt-layers/paths.ts +54 -0
  99. package/src/codex/prompt-layers/revision.ts +55 -0
  100. package/src/codex/prompt-layers/toml-edit.ts +163 -0
  101. package/src/codex/prompt-layers/toml-read.ts +181 -0
  102. package/src/codex/prompt-layers.ts +14 -520
  103. package/src/codex/quota-auto-refresh-state.ts +16 -0
  104. package/src/codex/quota-auto-refresh.ts +219 -0
  105. package/src/codex/quota-types.ts +51 -0
  106. package/src/codex/quota.ts +252 -93
  107. package/src/codex/reserve-availability.ts +177 -0
  108. package/src/codex/routing.ts +28 -9
  109. package/src/codex/shim.ts +53 -11
  110. package/src/codex/subagent-model-fallback.ts +23 -3
  111. package/src/combos/failover.ts +125 -7
  112. package/src/combos/identifiers.ts +89 -0
  113. package/src/combos/index.ts +4 -0
  114. package/src/combos/resolve.ts +80 -9
  115. package/src/combos/types.ts +20 -93
  116. package/src/config/subagent-models.ts +24 -0
  117. package/src/config.ts +156 -13
  118. package/src/generated/compatibility-version.json +454 -166
  119. package/src/generated/model-metadata.ts +1 -1
  120. package/src/integrations/journal.ts +65 -4
  121. package/src/integrations/store.ts +5 -0
  122. package/src/lab/events/limits.ts +4 -0
  123. package/src/lib/destination-policy.ts +31 -2
  124. package/src/lib/errors.ts +39 -0
  125. package/src/lib/provider-outbound.ts +69 -3
  126. package/src/lib/proxy-env.ts +22 -0
  127. package/src/lib/redact-folding.ts +176 -0
  128. package/src/lib/redact.ts +2 -175
  129. package/src/lib/state-store-sweeper.ts +20 -6
  130. package/src/lib/token-estimate.ts +94 -27
  131. package/src/lib/windows-user-principal.ts +53 -5
  132. package/src/oauth/anthropic-routing.ts +99 -3
  133. package/src/oauth/generic-account-failover.ts +36 -13
  134. package/src/oauth/index.ts +127 -26
  135. package/src/oauth/login-cli.ts +5 -0
  136. package/src/oauth/meta-muse.ts +117 -15
  137. package/src/oauth/pool-settings-capability.ts +15 -4
  138. package/src/providers/api-keys.ts +8 -10
  139. package/src/providers/default-aliases.ts +39 -0
  140. package/src/providers/derive.ts +10 -2
  141. package/src/providers/fastwire.ts +36 -7
  142. package/src/providers/initial-model-selection-runtime.ts +90 -0
  143. package/src/providers/initial-model-selection.ts +120 -0
  144. package/src/providers/key-failover.ts +134 -54
  145. package/src/providers/key-store.ts +11 -1
  146. package/src/providers/label.ts +1 -1
  147. package/src/providers/model-discovery.ts +76 -0
  148. package/src/providers/model-rename-startup.ts +72 -8
  149. package/src/providers/openai-sidecar.ts +17 -5
  150. package/src/providers/openai-tiers-destination.ts +102 -0
  151. package/src/providers/openai-tiers.ts +2 -99
  152. package/src/providers/opencode-go-transport.ts +41 -0
  153. package/src/providers/quota-key-accounts.ts +141 -0
  154. package/src/providers/quota-types.ts +9 -0
  155. package/src/providers/quota.ts +535 -104
  156. package/src/providers/registry.ts +60 -17
  157. package/src/providers/xai-responses-opt-in.ts +31 -5
  158. package/src/quota/reset-activation.ts +81 -0
  159. package/src/quota/reset-detector.ts +305 -0
  160. package/src/quota/reset-notify-config.ts +162 -0
  161. package/src/quota/reset-observer.ts +125 -0
  162. package/src/quota/reset-poller.ts +160 -0
  163. package/src/quota/reset-seen-store.ts +385 -0
  164. package/src/quota/reset-sinks.ts +199 -0
  165. package/src/quota/window-mapping.ts +106 -0
  166. package/src/responses/apply-patch-envelope.ts +46 -0
  167. package/src/responses/code-mode-helper-compat.ts +39 -1
  168. package/src/responses/custom-tool-compat.ts +10 -4
  169. package/src/responses/hosted-tool-policy.ts +12 -4
  170. package/src/responses/parser-content.ts +133 -0
  171. package/src/responses/parser-text-format.ts +24 -0
  172. package/src/responses/parser-tools.ts +188 -0
  173. package/src/responses/parser.ts +3 -326
  174. package/src/responses/state.ts +124 -28
  175. package/src/router.ts +48 -13
  176. package/src/routing/analytics.ts +1 -0
  177. package/src/routing/capability.ts +17 -4
  178. package/src/server/auth-cors.ts +7 -1
  179. package/src/server/background-lifecycle.ts +23 -1
  180. package/src/server/chat-completions.ts +25 -3
  181. package/src/server/claude-messages.ts +62 -5
  182. package/src/server/effort-row.ts +1 -1
  183. package/src/server/fast-row.ts +295 -0
  184. package/src/server/gui-static.ts +30 -4
  185. package/src/server/index.ts +107 -21
  186. package/src/server/management/agent-settings-routes.ts +2 -2
  187. package/src/server/management/combo-routes.ts +37 -9
  188. package/src/server/management/config-routes.ts +93 -2
  189. package/src/server/management/integration-routes.ts +108 -0
  190. package/src/server/management/model-routes.ts +13 -3
  191. package/src/server/management/model-rows.ts +20 -1
  192. package/src/server/management/native-integration-routes.ts +4 -1
  193. package/src/server/management/oauth-account-routes.ts +38 -10
  194. package/src/server/management/provider-routes.ts +34 -3
  195. package/src/server/management/quota-reset-routes.ts +57 -0
  196. package/src/server/management/route-registry.ts +7 -4
  197. package/src/server/management/shared.ts +19 -5
  198. package/src/server/management/system-routes.ts +3 -2
  199. package/src/server/management-api.ts +14 -2
  200. package/src/server/ports.ts +12 -2
  201. package/src/server/relay-eager.ts +38 -23
  202. package/src/server/relay.ts +4 -0
  203. package/src/server/request-log.ts +6 -0
  204. package/src/server/responses/codex-ws-correlation.ts +65 -0
  205. package/src/server/responses/codex-ws-exchange.ts +261 -0
  206. package/src/server/responses/codex-ws-metadata.ts +134 -0
  207. package/src/server/responses/codex-ws-pool.ts +162 -0
  208. package/src/server/responses/codex-ws-request.ts +87 -0
  209. package/src/server/responses/codex-ws-session.ts +93 -0
  210. package/src/server/responses/codex-ws-wire.ts +144 -0
  211. package/src/server/responses/collaboration.ts +41 -1
  212. package/src/server/responses/compact.ts +105 -12
  213. package/src/server/responses/core.ts +476 -57
  214. package/src/server/responses/fetch-helpers.ts +10 -3
  215. package/src/server/responses/input-admission.ts +16 -9
  216. package/src/server/responses/responses-field-backfill.ts +1 -1
  217. package/src/server/responses/ws-upstream.ts +34 -318
  218. package/src/server/responses-custom-tool-repair.ts +20 -4
  219. package/src/server/responses-undeclared-tool-guard.ts +100 -8
  220. package/src/server/safe-response-headers.ts +23 -0
  221. package/src/server/search.ts +9 -0
  222. package/src/server/subagent-models-startup.ts +27 -0
  223. package/src/server/system-env-shell.ts +238 -0
  224. package/src/server/system-env.ts +7 -234
  225. package/src/server/ws-bridge.ts +3 -25
  226. package/src/server/xai-responses-startup.ts +21 -0
  227. package/src/service-manager-probe.ts +1 -1
  228. package/src/service.ts +55 -16
  229. package/src/types/config.ts +108 -12
  230. package/src/types/provider.ts +36 -7
  231. package/src/types/tools.ts +26 -1
  232. package/src/types.ts +2 -0
  233. package/src/update/notify.ts +8 -2
  234. package/src/usage/cost.ts +38 -28
  235. package/src/usage/expected-prices.ts +34 -15
  236. package/src/usage/log.ts +2 -0
  237. package/src/usage/model-identity.ts +26 -0
  238. package/src/usage/summary.ts +15 -1
  239. package/src/vision/describe.ts +6 -0
  240. package/src/vision/image-rewrite.ts +108 -0
  241. package/src/vision/index.ts +19 -306
  242. package/src/vision/plan.ts +205 -0
  243. package/src/web-search/executor.ts +6 -0
  244. package/src/web-search/index.ts +8 -1
  245. package/gui/dist/assets/index-BU1tE0sr.js +0 -112
  246. package/gui/dist/assets/index-DL9-iS6J.css +0 -1
@@ -0,0 +1,385 @@
1
+ /**
2
+ * Durable "already notified" ledger plus a bounded ring of recent reset events.
3
+ *
4
+ * Exactly-once has to hold across a restart, because the whole point of a surprise reset is
5
+ * that it happens while nobody is watching. An in-memory set would re-notify every reset
6
+ * whose window is still open the next time the proxy starts.
7
+ *
8
+ * Deliberately NOT stored in config.json: this is high-frequency job state, and
9
+ * mutatePersistedConfig fails closed when the config did not come from a file.
10
+ */
11
+
12
+ import { existsSync, readFileSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ // Imported from the definition sites, NOT the ../config barrel. The barrel pulls 154 modules
15
+ // (~430 KB) including combos, account-store, pool-rotation and cursor discovery; these two
16
+ // cost 8. This store is reached from the once-per-pooled-response observation path, so the
17
+ // barrel would work directly against the boundary guard wp5 adds.
18
+ import { atomicWriteFile } from "../config/atomic-write";
19
+ import { getConfigDir } from "../config/paths";
20
+ import type { QuotaResetEvent, QuotaWindowObservation } from "./reset-detector";
21
+
22
+ const STATE_FILENAME = "quota-reset-state.json";
23
+ const PERSIST_DEBOUNCE_MS = 250;
24
+ /**
25
+ * Longest a pending write may be deferred by continued activity. See schedulePersist.
26
+ */
27
+ const MAX_PERSIST_DEFERRAL_MS = 1_000;
28
+
29
+ /**
30
+ * Age floor for pruning a claimed key.
31
+ *
32
+ * 90 days, not 30: a monthly window's key is legitimately older than a month while still
33
+ * current, and pruning it would let the same reset notify twice.
34
+ */
35
+ const CLAIM_MAX_AGE_MS = 90 * 24 * 60 * 60_000;
36
+ const MAX_CLAIMS = 512;
37
+ /**
38
+ * Hard ceiling for the claim map.
39
+ *
40
+ * MAX_CLAIMS is the soft budget, honoured by evicting settled claims. When every claim is
41
+ * still live there is nothing safe to evict, so without a hard stop the map — and the JSON
42
+ * rewritten beside it — grows without limit. At this ceiling we evict the furthest-future
43
+ * deadline and accept one theoretical duplicate, which is strictly better than unbounded growth.
44
+ */
45
+ const HARD_MAX_CLAIMS = 2 * MAX_CLAIMS;
46
+ const MAX_RING_EVENTS = 100;
47
+ /** One row per (scope, accountTag). A large pool plus several providers stays well inside this. */
48
+ const MAX_OBSERVED_SCOPES = 64;
49
+
50
+ type ClaimRecord = {
51
+ /** When the claim was made. */
52
+ at: number;
53
+ /** The window deadline this claim belongs to; a future value means the claim is live. */
54
+ resetAt?: number;
55
+ };
56
+
57
+ type StateFile = {
58
+ version: 1;
59
+ claims: Record<string, ClaimRecord>;
60
+ events: QuotaResetEvent[];
61
+ /** Last observed windows per "scope\u0000accountTag". Absent in files written before this field. */
62
+ observed?: Record<string, QuotaWindowObservation[]>;
63
+ /** Per-install salt for account tagging. Created once, then stable. */
64
+ accountSalt?: string;
65
+ };
66
+
67
+ const claims = new Map<string, ClaimRecord>();
68
+ let ring: QuotaResetEvent[] = [];
69
+ const observed = new Map<string, QuotaWindowObservation[]>();
70
+ let hydrated = false;
71
+ let persistTimer: ReturnType<typeof setTimeout> | null = null;
72
+ /** When the currently pending debounced write was FIRST scheduled, or null if none is pending. */
73
+ let firstDeferredPersistAt: number | null = null;
74
+ let accountSalt: string | null = null;
75
+
76
+ function statePath(): string {
77
+ return join(getConfigDir(), STATE_FILENAME);
78
+ }
79
+
80
+ function hydrate(): void {
81
+ if (hydrated) return;
82
+ hydrated = true;
83
+ try {
84
+ const path = statePath();
85
+ if (!existsSync(path)) return;
86
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as StateFile;
87
+ if (!parsed || parsed.version !== 1) return;
88
+ if (parsed.claims && typeof parsed.claims === "object") {
89
+ for (const [key, record] of Object.entries(parsed.claims)) {
90
+ if (!record || typeof record.at !== "number") continue;
91
+ claims.set(key, {
92
+ at: record.at,
93
+ ...(typeof record.resetAt === "number" ? { resetAt: record.resetAt } : {}),
94
+ });
95
+ }
96
+ }
97
+ if (Array.isArray(parsed.events)) ring = parsed.events.slice(-MAX_RING_EVENTS);
98
+ if (parsed.observed && typeof parsed.observed === "object") {
99
+ for (const [key, windows] of Object.entries(parsed.observed)) {
100
+ if (Array.isArray(windows)) observed.set(key, windows);
101
+ }
102
+ }
103
+ if (typeof parsed.accountSalt === "string" && parsed.accountSalt.length >= 16) {
104
+ accountSalt = parsed.accountSalt;
105
+ }
106
+ } catch {
107
+ // A corrupt or partially written cache must never break a quota refresh. Starting empty
108
+ // risks one duplicate notification; throwing here would break the write that triggered us.
109
+ claims.clear();
110
+ ring = [];
111
+ observed.clear();
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Per-install salt, created on first use and persisted.
117
+ *
118
+ * Without it an account tag is a bare unkeyed hash of an email — brute-forceable in tens of
119
+ * guesses, which turns the webhook payload into an account-identity oracle for whoever
120
+ * receives it. Salted, the tag stays stable for this install (so the persisted idempotence
121
+ * key still works across restarts) and is unlinkable to anyone without the salt.
122
+ */
123
+ export function quotaResetAccountSalt(): string {
124
+ hydrate();
125
+ if (accountSalt) return accountSalt;
126
+ accountSalt = crypto.randomUUID().replaceAll("-", "");
127
+ persistNow();
128
+ return accountSalt;
129
+ }
130
+
131
+ function stateFileBody(): StateFile {
132
+ return {
133
+ version: 1,
134
+ claims: Object.fromEntries(claims),
135
+ events: ring,
136
+ observed: Object.fromEntries(observed),
137
+ ...(accountSalt !== null ? { accountSalt } : {}),
138
+ };
139
+ }
140
+
141
+ /**
142
+ * Write immediately, for anything whose loss breaks correctness.
143
+ *
144
+ * A claim MUST NOT ride the debounce: the timer is unref'd, so a process exiting within
145
+ * 250 ms of claiming — the common case when detection runs on the last pooled request before
146
+ * shutdown — never writes it, and the next start re-notifies. That is precisely the
147
+ * across-a-restart guarantee this store exists for, and a shutdown hook would not cover SIGKILL.
148
+ *
149
+ * Returns whether the write actually landed. The error is still swallowed rather than thrown —
150
+ * a quota observation must not fail on a full disk — but a caller that treats success as
151
+ * "durably claimed" needs to be told the difference.
152
+ */
153
+ function persistNow(): boolean {
154
+ if (persistTimer) {
155
+ clearTimeout(persistTimer);
156
+ persistTimer = null;
157
+ }
158
+ firstDeferredPersistAt = null;
159
+ try {
160
+ atomicWriteFile(statePath(), `${JSON.stringify(stateFileBody())}\n`);
161
+ return true;
162
+ } catch {
163
+ // Best-effort persistence only.
164
+ return false;
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Debounce with a MAXIMUM STALENESS cap.
170
+ *
171
+ * A plain re-arming debounce is starvable: it clears and re-sets the timer on every
172
+ * observation, so any write cadence faster than the debounce pushes the deadline out forever.
173
+ * Measured on the unfixed version: 75 observations at 40 ms intervals produced ZERO disk writes,
174
+ * and 200 at 10 ms also produced zero. A busy pooled install that is then SIGKILLed (container
175
+ * stop, OOM) loses its entire baseline and re-baselines on restart, silently missing any reset
176
+ * that spans the gap — which defeats the across-a-restart guarantee this store exists for.
177
+ *
178
+ * So the deferral is bounded: once a write has been pending for MAX_PERSIST_DEFERRAL_MS, the
179
+ * next call writes immediately instead of deferring again. At 0.29 ms per serialize-and-write,
180
+ * a forced write every second under sustained load costs nothing measurable.
181
+ *
182
+ * Claims deliberately do NOT use this path — they write synchronously, because an unref'd timer
183
+ * cannot be trusted to fire before process exit.
184
+ */
185
+ function schedulePersist(): void {
186
+ const now = Date.now();
187
+ if (firstDeferredPersistAt === null) firstDeferredPersistAt = now;
188
+ else if (now - firstDeferredPersistAt >= MAX_PERSIST_DEFERRAL_MS) {
189
+ // persistNow clears the timer and resets the window below.
190
+ persistNow();
191
+ return;
192
+ }
193
+ if (persistTimer) clearTimeout(persistTimer);
194
+ persistTimer = setTimeout(() => {
195
+ persistTimer = null;
196
+ firstDeferredPersistAt = null;
197
+ try {
198
+ atomicWriteFile(statePath(), `${JSON.stringify(stateFileBody())}\n`);
199
+ } catch {
200
+ // Best-effort persistence, matching the codex quota cache.
201
+ }
202
+ }, PERSIST_DEBOUNCE_MS);
203
+ persistTimer.unref?.();
204
+ }
205
+
206
+ /**
207
+ * Drop claims that are both old and settled. A live deadline is never pruned.
208
+ *
209
+ * `now` comes from the wall clock, not from the caller's `at`: a backdated or clock-skewed
210
+ * claim must not change the retention of unrelated keys.
211
+ */
212
+ function prune(now = Date.now()): void {
213
+ for (const [key, record] of claims) {
214
+ // A claim with no deadline is UNKNOWN, not settled. Clockless windows are the common case
215
+ // for credit-balance providers, so treating them as settled would make them the first
216
+ // thing evicted. They still age out below, just without that preference.
217
+ if (record.resetAt === undefined || record.resetAt > now) continue;
218
+ if (now - record.at <= CLAIM_MAX_AGE_MS) continue;
219
+ claims.delete(key);
220
+ }
221
+ for (const [key, record] of claims) {
222
+ if (record.resetAt !== undefined) continue;
223
+ if (now - record.at <= CLAIM_MAX_AGE_MS) continue;
224
+ claims.delete(key);
225
+ }
226
+ if (claims.size <= MAX_CLAIMS) return;
227
+ // Over budget: evict the oldest SETTLED claims only. Evicting a live one would trade a
228
+ // memory bound for a duplicate notification, which is the bug this store prevents.
229
+ const settled = [...claims.entries()]
230
+ .filter(([, record]) => record.resetAt === undefined || record.resetAt <= now)
231
+ .sort((left, right) => left[1].at - right[1].at);
232
+ for (const [key] of settled) {
233
+ if (claims.size <= MAX_CLAIMS) break;
234
+ claims.delete(key);
235
+ }
236
+ if (claims.size <= HARD_MAX_CLAIMS) return;
237
+ // Every remaining claim is live. Evict the furthest-future deadlines first: least likely to
238
+ // be re-observed soon, so a duplicate there is least disruptive.
239
+ const live = [...claims.entries()].sort(
240
+ (left, right) => (right[1].resetAt ?? 0) - (left[1].resetAt ?? 0),
241
+ );
242
+ for (const [key] of live) {
243
+ if (claims.size <= HARD_MAX_CLAIMS) break;
244
+ claims.delete(key);
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Atomically claim a reset key. Returns true for the FIRST caller only.
250
+ *
251
+ * One synchronous check-and-set rather than a separate has/mark pair: a poller tick and a
252
+ * live pooled response can observe the same transition, and two callers that both read
253
+ * "unseen" would both notify. There is no await inside, so with Bun's single-threaded turn
254
+ * semantics this is indivisible with respect to other observers.
255
+ */
256
+ export function claimQuotaReset(key: string, at: number, resetAt?: number): boolean {
257
+ hydrate();
258
+ if (claims.has(key)) return false;
259
+ claims.set(key, { at, ...(resetAt !== undefined ? { resetAt } : {}) });
260
+ prune();
261
+ // prune() can evict the claim just added — an already-past deadline older than
262
+ // CLAIM_MAX_AGE_MS qualifies — and an evicted claim is not a claim.
263
+ if (!claims.has(key)) return false;
264
+ // Synchronous: a lost claim means a duplicate notification after restart, and claims are
265
+ // rare (one per real reset), so the write cost is irrelevant.
266
+ // A failed write is reported rather than swallowed: the caller reads true as "safe to
267
+ // dispatch", and an unpersisted claim re-notifies on the next start.
268
+ return persistNow();
269
+ }
270
+
271
+ /** Read-only probe. Never used to gate a notification — see claimQuotaReset. */
272
+ export function hasSeenQuotaReset(key: string): boolean {
273
+ hydrate();
274
+ return claims.has(key);
275
+ }
276
+
277
+ export function recordQuotaResetEvent(event: QuotaResetEvent): void {
278
+ hydrate();
279
+ ring.push(event);
280
+ if (ring.length > MAX_RING_EVENTS) ring = ring.slice(-MAX_RING_EVENTS);
281
+ schedulePersist();
282
+ }
283
+
284
+ /** Recent events, newest first. */
285
+ export function listRecentQuotaResetEvents(limit = MAX_RING_EVENTS): QuotaResetEvent[] {
286
+ hydrate();
287
+ const bounded = Math.max(1, Math.min(limit, MAX_RING_EVENTS));
288
+ return [...ring].reverse().slice(0, bounded);
289
+ }
290
+
291
+ function observedKey(scope: string, accountTag: string): string {
292
+ return `${scope}\u0000${accountTag}`;
293
+ }
294
+
295
+ /**
296
+ * Store the newly observed windows for one (scope, accountTag) and return what was there
297
+ * before, or undefined on the first ever observation.
298
+ *
299
+ * The detector owns this map rather than borrowing a caller's previous value, because
300
+ * neither upstream cache can supply one reliably. The provider report cache keys itself on a
301
+ * digest that INCLUDES quota values and updatedAt (src/providers/quota.ts:193 via :155), so
302
+ * its `previous` is empty precisely when a reset happened; and it is process-memory only, so
303
+ * it has no answer at all after a restart. This map is keyed by identity and persisted.
304
+ */
305
+ export function swapLastObservedWindows(
306
+ scope: string,
307
+ accountTag: string,
308
+ windows: ReadonlyArray<QuotaWindowObservation>,
309
+ ): ReadonlyArray<QuotaWindowObservation> | undefined {
310
+ hydrate();
311
+ const key = observedKey(scope, accountTag);
312
+ const previous = observed.get(key);
313
+ // Delete before set so the row moves to the END of the insertion order, making this a
314
+ // true LRU. Re-setting an existing key does NOT move it in a Map, so without the delete
315
+ // the eviction below removed the EARLIEST-INSERTED row — which on a real install is the
316
+ // long-lived codex account observed on every response, while 63 transient rows survived.
317
+ // Measured: the hottest scope was evicted and its next genuine scheduled reset was
318
+ // silently missed, because a re-baselined row has no previous value to diff against.
319
+ observed.delete(key);
320
+ observed.set(key, windows.map(window => ({ ...window })));
321
+ if (observed.size > MAX_OBSERVED_SCOPES) {
322
+ // Least-recently-observed first. Evicting only costs a re-baseline, never a duplicate
323
+ // notification, because the claim ledger is separate — but a re-baseline DOES cost the
324
+ // next reset on that row, which is why picking the genuinely abandoned row matters.
325
+ const oldest = observed.keys().next();
326
+ if (!oldest.done && oldest.value !== key) observed.delete(oldest.value);
327
+ }
328
+ schedulePersist();
329
+ return previous;
330
+ }
331
+
332
+ /**
333
+ * Forget the baseline for one (scope, accountTag), or for every scope when no tag is given.
334
+ *
335
+ * Called when a quota row is deliberately cleared — reauth, sign-out, account removal. The
336
+ * quota row and this baseline live in different files, so clearing only the former left the
337
+ * observer holding the pre-clear percentages: the first fresh write after a reauth of a used
338
+ * account then read as a surprise reset (measured 91% -> 0%). The existing regression test
339
+ * missed it because it also called resetQuotaResetStoreForTests(), which real reauth does
340
+ * not do — the test simulated something that never happens.
341
+ *
342
+ * Deliberately does NOT touch the claim ledger. A cleared row must not re-notify a reset it
343
+ * already reported, and claims are what prevent that.
344
+ */
345
+ export function forgetLastObservedWindows(scope: string, accountTag?: string): void {
346
+ hydrate();
347
+ if (accountTag !== undefined) {
348
+ if (!observed.delete(observedKey(scope, accountTag))) return;
349
+ schedulePersist();
350
+ return;
351
+ }
352
+ // No tag: the caller cleared everything for this scope. The account tag is a salted hash,
353
+ // so it cannot be reversed — matching on the scope prefix is the only available form.
354
+ const prefix = `${scope}\u0000`;
355
+ let removed = false;
356
+ for (const key of [...observed.keys()]) {
357
+ if (key.startsWith(prefix)) removed = observed.delete(key) || removed;
358
+ }
359
+ if (removed) schedulePersist();
360
+ }
361
+
362
+ /** Test-only: forget in-memory state so the next call re-reads OPENCODEX_HOME. */
363
+ export function resetQuotaResetStoreForTests(): void {
364
+ claims.clear();
365
+ ring = [];
366
+ observed.clear();
367
+ hydrated = false;
368
+ accountSalt = null;
369
+ if (persistTimer) {
370
+ clearTimeout(persistTimer);
371
+ persistTimer = null;
372
+ }
373
+ firstDeferredPersistAt = null;
374
+ }
375
+
376
+ /** Test-only: flush the debounced write immediately. */
377
+ export function flushQuotaResetStoreForTests(): void {
378
+ persistNow();
379
+ }
380
+
381
+ /** Test-only: claim-map size, for asserting retention bounds. Exposes no keys. */
382
+ export function claimCountForTests(): number {
383
+ hydrate();
384
+ return claims.size;
385
+ }
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Delivery sinks for a detected quota reset: an HTTP webhook and a local command.
3
+ *
4
+ * Both are best-effort and independently isolated. Neither ever throws to the caller, and one
5
+ * failing never suppresses the other — a reset notification is a courtesy, and a courtesy that
6
+ * can break the quota write that triggered it is a defect.
7
+ *
8
+ * There is deliberately NO retry. The wp3 observer claims the idempotence key before dispatch,
9
+ * so a retry here could only ever duplicate a delivery the ledger already considers done; and a
10
+ * reset notification is interesting only while it is fresh.
11
+ *
12
+ * Imported lazily by the activation path, never statically from a core file: this module
13
+ * reaches the destination policy and the config barrel, and
14
+ * tests/usage/quota-reset-core-boundary.test.ts enforces that none of that lands on the request path.
15
+ */
16
+
17
+ import { signalWithTimeout } from "../lib/abort";
18
+ import { assertUrlResolvesPublic } from "../lib/destination-policy";
19
+ import { cancelResponseBodyBestEffort } from "../lib/upstream-retry";
20
+ import type { QuotaResetEvent } from "./reset-detector";
21
+ import type { ResolvedQuotaResetNotify } from "./reset-notify-config";
22
+
23
+ export type QuotaResetSinkName = "webhook" | "command";
24
+
25
+ /**
26
+ * Outcome of one delivery attempt.
27
+ *
28
+ * `reason` is a CLOSED UNION on purpose. An upstream body, a resolved address, or the webhook
29
+ * URL itself would all be sensitive — the URL is the credential for Slack and Discord — and this
30
+ * value reaches logs and the management API.
31
+ */
32
+ export type QuotaResetDeliveryResult = {
33
+ readonly sink: QuotaResetSinkName;
34
+ readonly ok: boolean;
35
+ readonly reason?: "blocked-destination" | "timeout" | "http-error" | "spawn-failed";
36
+ };
37
+
38
+ /**
39
+ * The delivered payload.
40
+ *
41
+ * Closed-union labels and numbers only: no email, no account id, no token, no filesystem path,
42
+ * no URL. `accountTag` is a salted hash whose salt never leaves the install.
43
+ *
44
+ * The TYPE is the enforcement. `bun run privacy:scan` reads repository text, not runtime output,
45
+ * so it cannot check this — stating the obligation in the type is what keeps a later field
46
+ * addition honest.
47
+ */
48
+ type QuotaResetPayload = {
49
+ readonly type: "quota_reset";
50
+ readonly kind: QuotaResetEvent["kind"];
51
+ readonly scope: string;
52
+ readonly accountTag: string;
53
+ readonly window: string;
54
+ readonly percentBefore?: number;
55
+ readonly percentAfter?: number;
56
+ readonly previousResetAt?: number;
57
+ readonly resetAt?: number;
58
+ readonly detectedAt: number;
59
+ };
60
+
61
+ /**
62
+ * Build the payload by NAMING every field, never by spreading the event.
63
+ *
64
+ * A spread would silently forward whatever the detector gains next — including the internal
65
+ * idempotence `key`, which encodes the scope and tag and has no business crossing a webhook
66
+ * boundary to a third party.
67
+ */
68
+ function payloadFor(event: QuotaResetEvent): QuotaResetPayload {
69
+ return {
70
+ type: "quota_reset",
71
+ kind: event.kind,
72
+ scope: event.scope,
73
+ accountTag: event.accountTag,
74
+ window: event.window,
75
+ ...(event.percentBefore !== undefined ? { percentBefore: event.percentBefore } : {}),
76
+ ...(event.percentAfter !== undefined ? { percentAfter: event.percentAfter } : {}),
77
+ ...(event.previousResetAt !== undefined ? { previousResetAt: event.previousResetAt } : {}),
78
+ ...(event.resetAt !== undefined ? { resetAt: event.resetAt } : {}),
79
+ detectedAt: event.detectedAt,
80
+ };
81
+ }
82
+
83
+ /** Test seam for the payload contract, so a test cannot drift from what is actually sent. */
84
+ export function quotaResetPayloadForTests(event: QuotaResetEvent): unknown {
85
+ return payloadFor(event);
86
+ }
87
+
88
+ async function deliverWebhook(
89
+ json: string,
90
+ config: ResolvedQuotaResetNotify,
91
+ ): Promise<QuotaResetDeliveryResult> {
92
+ const url = config.webhookUrl;
93
+ if (url === undefined) return { sink: "webhook", ok: true };
94
+
95
+ // An operator-supplied URL is an SSRF surface: this process can reach loopback services and
96
+ // cloud metadata endpoints that the operator's browser cannot. The repository already owns
97
+ // this policy, so the check is reused rather than reinvented. Self-hosted receivers opt in.
98
+ if (!config.allowPrivateNetwork) {
99
+ try {
100
+ await assertUrlResolvesPublic(url);
101
+ } catch {
102
+ return { sink: "webhook", ok: false, reason: "blocked-destination" };
103
+ }
104
+ }
105
+
106
+ const timeout = signalWithTimeout(config.timeoutMs);
107
+ try {
108
+ const response = await fetch(url, {
109
+ method: "POST",
110
+ headers: { "content-type": "application/json" },
111
+ body: json,
112
+ // The destination check above validated THIS url. Following a redirect would send the
113
+ // payload somewhere unvalidated, so a hop is refused rather than re-validated: the
114
+ // operator can configure the final URL directly, which is the stance
115
+ // providerRedirectError already takes for provider traffic.
116
+ redirect: "manual",
117
+ signal: timeout.signal,
118
+ });
119
+ // Nothing reads the body, and an undrained response holds the connection open.
120
+ cancelResponseBodyBestEffort(response);
121
+ if (response.status >= 300 && response.status < 400) {
122
+ return { sink: "webhook", ok: false, reason: "blocked-destination" };
123
+ }
124
+ if (!response.ok) return { sink: "webhook", ok: false, reason: "http-error" };
125
+ return { sink: "webhook", ok: true };
126
+ } catch (error) {
127
+ const aborted = error instanceof Error
128
+ && (error.name === "TimeoutError" || error.name === "AbortError");
129
+ return { sink: "webhook", ok: false, reason: aborted ? "timeout" : "http-error" };
130
+ } finally {
131
+ // Without this the timer keeps the event loop alive for up to timeoutMs after a fast
132
+ // response, which on a short-lived CLI invocation delays exit for no reason.
133
+ timeout.cleanup();
134
+ }
135
+ }
136
+
137
+ async function deliverCommand(
138
+ json: string,
139
+ config: ResolvedQuotaResetNotify,
140
+ ): Promise<QuotaResetDeliveryResult> {
141
+ const command = config.command;
142
+ if (command === undefined || command.length === 0) return { sink: "command", ok: true };
143
+
144
+ try {
145
+ // argv form, NOT a shell string: an operator-supplied command must not become an injection
146
+ // surface, and there is no shell here to interpret metacharacters.
147
+ const proc = Bun.spawn([...command], {
148
+ // Encoded bytes, not a string. Bun 1.4.0 throws ERR_INVALID_ARG_TYPE on a plain string
149
+ // here, and no existing call site in this repository pipes stdin, so there was no
150
+ // in-repo precedent to copy.
151
+ stdin: new TextEncoder().encode(json),
152
+ // The event is already delivered by writing it to stdin; the command's own chatter is not
153
+ // ours to relay, and inheriting it would interleave into whatever is on the terminal.
154
+ stdout: "ignore",
155
+ stderr: "ignore",
156
+ });
157
+ const exitCode = await proc.exited;
158
+ if (exitCode !== 0) return { sink: "command", ok: false, reason: "spawn-failed" };
159
+ return { sink: "command", ok: true };
160
+ } catch {
161
+ // A missing binary or a permission error. The message could contain a filesystem path, so
162
+ // it is deliberately not carried through.
163
+ return { sink: "command", ok: false, reason: "spawn-failed" };
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Deliver one event to every configured sink.
169
+ *
170
+ * Sinks run CONCURRENTLY and are settled independently, so a webhook that sits until its
171
+ * timeout does not delay the local command. Never rejects.
172
+ */
173
+ export async function deliverQuotaResetEvent(
174
+ event: QuotaResetEvent,
175
+ config: ResolvedQuotaResetNotify,
176
+ ): Promise<QuotaResetDeliveryResult[]> {
177
+ // Filtered here as well as at the sink registration, because this function is the public
178
+ // entry point and a caller should not be able to deliver a kind the operator excluded.
179
+ if (!config.kinds.has(event.kind)) return [];
180
+
181
+ const json = JSON.stringify(payloadFor(event));
182
+ const attempts: Array<Promise<QuotaResetDeliveryResult>> = [];
183
+ if (config.webhookUrl !== undefined) attempts.push(deliverWebhook(json, config));
184
+ if (config.command !== undefined && config.command.length > 0) {
185
+ attempts.push(deliverCommand(json, config));
186
+ }
187
+ if (attempts.length === 0) return [];
188
+
189
+ const settled = await Promise.allSettled(attempts);
190
+ return settled.map((result, index) => {
191
+ if (result.status === "fulfilled") return result.value;
192
+ // Both helpers catch internally, so this is unreachable in practice — but a rejected
193
+ // promise must still not propagate out of a best-effort notifier.
194
+ const sink: QuotaResetSinkName = index === 0 && config.webhookUrl !== undefined
195
+ ? "webhook"
196
+ : "command";
197
+ return { sink, ok: false, reason: "spawn-failed" };
198
+ });
199
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Provider- and Codex-specific quota shapes mapped to the detector's neutral window list.
3
+ *
4
+ * Separate from the detector so the detector stays free of any dependency on either quota
5
+ * subsystem's types, and separate from the observer so the seams can build observations
6
+ * without loading the sink registry.
7
+ */
8
+
9
+ import type { QuotaWindowObservation } from "./reset-detector";
10
+
11
+ type CodexLikeQuota = {
12
+ shortPercent?: number;
13
+ shortResetAt?: number;
14
+ shortWindowSeconds?: number;
15
+ weeklyPercent?: number;
16
+ weeklyResetAt?: number;
17
+ monthlyPercent?: number;
18
+ monthlyResetAt?: number;
19
+ customWindows?: ReadonlyArray<{ label: string; percent: number; resetAt?: number }>;
20
+ };
21
+
22
+ type ProviderLikeQuota = {
23
+ fiveHourPercent?: number;
24
+ fiveHourResetAt?: number;
25
+ weeklyPercent?: number;
26
+ weeklyResetAt?: number;
27
+ monthlyPercent?: number;
28
+ monthlyResetAt?: number;
29
+ customWindows?: ReadonlyArray<{ label: string; percent: number; resetAt?: number }>;
30
+ };
31
+
32
+ function window(
33
+ label: string,
34
+ percent: number | undefined,
35
+ resetAt: number | undefined,
36
+ windowSeconds?: number,
37
+ ): QuotaWindowObservation | null {
38
+ // A window with neither a percent nor a clock carries no information. Emitting it would
39
+ // only create a baseline that can never produce a transition.
40
+ if (percent === undefined && resetAt === undefined) return null;
41
+ return {
42
+ window: label,
43
+ ...(percent !== undefined ? { percent } : {}),
44
+ ...(resetAt !== undefined ? { resetAt } : {}),
45
+ ...(typeof windowSeconds === "number" && Number.isFinite(windowSeconds) && windowSeconds > 0
46
+ ? { windowSeconds }
47
+ : {}),
48
+ };
49
+ }
50
+
51
+ /**
52
+ * Nominal lengths for the fixed labels, in seconds.
53
+ *
54
+ * The detector uses a window length only to bound how much of a percent drop natural decay
55
+ * can explain in a ROLLING window. Upstream states a length for the short window
56
+ * (shortWindowSeconds) but not for the others, so these supply it. Weekly and monthly are
57
+ * calendar-anchored rather than rolling on every provider observed so far, but stating a
58
+ * length is still the conservative choice: it can only suppress a drop that is small relative
59
+ * to the elapsed share of a WEEK, which no genuine reset is.
60
+ */
61
+ const NOMINAL_WINDOW_SECONDS: Readonly<Record<string, number>> = {
62
+ "5h": 5 * 3600,
63
+ weekly: 7 * 24 * 3600,
64
+ monthly: 30 * 24 * 3600,
65
+ };
66
+
67
+ function customWindows(
68
+ entries: ReadonlyArray<{ label: string; percent: number; resetAt?: number }> | undefined,
69
+ ): QuotaWindowObservation[] {
70
+ if (!entries) return [];
71
+ const out: QuotaWindowObservation[] = [];
72
+ for (const entry of entries) {
73
+ if (typeof entry?.label !== "string") continue;
74
+ const mapped = window(`custom:${entry.label}`, entry.percent, entry.resetAt);
75
+ if (mapped) out.push(mapped);
76
+ }
77
+ return out;
78
+ }
79
+
80
+ /** Windows absent from the snapshot are omitted, never zero-filled: absence is not 0%. */
81
+ export function codexWindowObservations(quota: CodexLikeQuota): QuotaWindowObservation[] {
82
+ const out: QuotaWindowObservation[] = [];
83
+ for (const mapped of [
84
+ // Prefer the length upstream states for the short window; fall back to the nominal 5h.
85
+ window("5h", quota.shortPercent, quota.shortResetAt, quota.shortWindowSeconds ?? NOMINAL_WINDOW_SECONDS["5h"]),
86
+ window("weekly", quota.weeklyPercent, quota.weeklyResetAt, NOMINAL_WINDOW_SECONDS["weekly"]),
87
+ window("monthly", quota.monthlyPercent, quota.monthlyResetAt, NOMINAL_WINDOW_SECONDS["monthly"]),
88
+ ]) {
89
+ if (mapped) out.push(mapped);
90
+ }
91
+ out.push(...customWindows(quota.customWindows));
92
+ return out;
93
+ }
94
+
95
+ export function providerWindowObservations(quota: ProviderLikeQuota): QuotaWindowObservation[] {
96
+ const out: QuotaWindowObservation[] = [];
97
+ for (const mapped of [
98
+ window("5h", quota.fiveHourPercent, quota.fiveHourResetAt, NOMINAL_WINDOW_SECONDS["5h"]),
99
+ window("weekly", quota.weeklyPercent, quota.weeklyResetAt, NOMINAL_WINDOW_SECONDS["weekly"]),
100
+ window("monthly", quota.monthlyPercent, quota.monthlyResetAt, NOMINAL_WINDOW_SECONDS["monthly"]),
101
+ ]) {
102
+ if (mapped) out.push(mapped);
103
+ }
104
+ out.push(...customWindows(quota.customWindows));
105
+ return out;
106
+ }