@bitkyc08/opencodex 2.8.2-preview.20260731 → 2.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/README.md +24 -0
  2. package/bin/ocx.mjs +32 -4
  3. package/gui/dist/assets/index-CHwf3tTD.css +1 -0
  4. package/gui/dist/assets/index-CuVjugeE.js +67 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/adapters/anthropic-image-normalize.ts +114 -20
  8. package/src/adapters/anthropic.ts +86 -5
  9. package/src/adapters/azure.ts +3 -3
  10. package/src/adapters/base.ts +7 -3
  11. package/src/adapters/cursor/discovery.ts +14 -4
  12. package/src/adapters/cursor/effort-map.ts +18 -6
  13. package/src/adapters/cursor/framing.ts +102 -27
  14. package/src/adapters/cursor/kv-store.ts +30 -3
  15. package/src/adapters/cursor/live-models.ts +22 -2
  16. package/src/adapters/cursor/live-transport.ts +245 -49
  17. package/src/adapters/cursor/mcp-manager.ts +105 -8
  18. package/src/adapters/cursor/native-exec-mcp.ts +5 -3
  19. package/src/adapters/cursor/native-exec-shell.ts +296 -14
  20. package/src/adapters/cursor/native-exec.ts +381 -33
  21. package/src/adapters/cursor/protobuf-events.ts +28 -1
  22. package/src/adapters/cursor/protobuf-request.ts +71 -39
  23. package/src/adapters/cursor/request-builder.ts +2 -2
  24. package/src/adapters/cursor/transport.ts +2 -0
  25. package/src/adapters/cursor.ts +13 -2
  26. package/src/adapters/google-antigravity-replay.ts +184 -17
  27. package/src/adapters/google.ts +58 -8
  28. package/src/adapters/kiro-thinking.ts +23 -9
  29. package/src/adapters/kiro-tools.ts +49 -18
  30. package/src/adapters/kiro.ts +377 -133
  31. package/src/adapters/mimo-free.ts +36 -4
  32. package/src/adapters/openai-chat.ts +89 -14
  33. package/src/adapters/openai-responses.ts +130 -14
  34. package/src/adapters/run-turn-queue.ts +7 -1
  35. package/src/bridge.ts +466 -69
  36. package/src/chat/outbound.ts +144 -38
  37. package/src/claude/inbound-debug.ts +53 -8
  38. package/src/claude/outbound.ts +224 -38
  39. package/src/cli/agent-driven.ts +34 -1
  40. package/src/cli/catalog-prewarm.ts +5 -2
  41. package/src/cli/doctor.ts +12 -0
  42. package/src/cli/export-command.ts +187 -0
  43. package/src/cli/help.ts +11 -0
  44. package/src/cli/index.ts +13 -3
  45. package/src/cli/opencode.ts +36 -151
  46. package/src/cli/star-prompt.ts +13 -4
  47. package/src/cli/status-oauth.ts +12 -2
  48. package/src/clients/config-export.ts +377 -0
  49. package/src/codex/account-runtime-state.ts +19 -1
  50. package/src/codex/account-store.ts +162 -82
  51. package/src/codex/auth-api.ts +467 -159
  52. package/src/codex/auth-context.ts +15 -2
  53. package/src/codex/catalog/aggregation.ts +15 -0
  54. package/src/codex/catalog/provider-fetch.ts +29 -0
  55. package/src/codex/catalog/sync.ts +7 -1
  56. package/src/codex/inject.ts +5 -5
  57. package/src/codex/main-account-cache.ts +8 -1
  58. package/src/codex/model-cache.ts +81 -2
  59. package/src/codex/pool-rotation.ts +39 -0
  60. package/src/codex/project-config-warnings.ts +12 -1
  61. package/src/codex/quota.ts +35 -3
  62. package/src/codex/routing.ts +46 -1
  63. package/src/codex/shim.ts +10 -4
  64. package/src/codex/subagent-model-fallback.ts +12 -0
  65. package/src/codex/websocket-registry.ts +27 -0
  66. package/src/combos/failover.ts +31 -1
  67. package/src/combos/request.ts +9 -0
  68. package/src/combos/resolve.ts +60 -4
  69. package/src/combos/types.ts +12 -0
  70. package/src/config.ts +510 -55
  71. package/src/github/star-state.ts +13 -1
  72. package/src/images/fulfill.ts +39 -1
  73. package/src/images/loop.ts +52 -12
  74. package/src/lib/admission.ts +83 -0
  75. package/src/lib/app-owned-memory-stores.ts +173 -0
  76. package/src/lib/app-owned-memory.ts +265 -0
  77. package/src/lib/bun-stream-caps.ts +31 -7
  78. package/src/lib/config-ownership.ts +33 -0
  79. package/src/lib/crash-guard.ts +65 -5
  80. package/src/lib/debug-log-buffer.ts +47 -6
  81. package/src/lib/errors.ts +3 -0
  82. package/src/lib/gcp-adc.ts +40 -2
  83. package/src/lib/injection-debug-log.ts +26 -2
  84. package/src/lib/sidecar-tracker.ts +5 -2
  85. package/src/lib/sse-decoder.ts +257 -37
  86. package/src/lib/state-store-registrations.ts +109 -0
  87. package/src/lib/state-store-sweeper.ts +184 -0
  88. package/src/lib/translator-budget.ts +356 -0
  89. package/src/lib/windows-secret-acl.ts +33 -12
  90. package/src/lib/winsw.ts +8 -1
  91. package/src/oauth/anthropic-routing.ts +31 -7
  92. package/src/oauth/google-antigravity.ts +2 -1
  93. package/src/oauth/health.ts +30 -12
  94. package/src/oauth/index.ts +127 -23
  95. package/src/oauth/kiro.ts +10 -2
  96. package/src/oauth/store.ts +165 -18
  97. package/src/oauth/token-guardian.ts +43 -4
  98. package/src/oauth/types.ts +2 -1
  99. package/src/providers/base-url-choices.ts +10 -0
  100. package/src/providers/derive.ts +12 -0
  101. package/src/providers/key-failover.ts +12 -0
  102. package/src/providers/openai-sidecar.ts +4 -1
  103. package/src/providers/quota.ts +68 -7
  104. package/src/providers/registry.ts +279 -3
  105. package/src/responses/parser.ts +5 -1
  106. package/src/responses/spill-store.ts +394 -0
  107. package/src/responses/state.ts +520 -102
  108. package/src/router.ts +18 -1
  109. package/src/server/adapter-resolve.ts +20 -3
  110. package/src/server/auth-cors.ts +121 -28
  111. package/src/server/chat-completions.ts +57 -12
  112. package/src/server/claude-messages.ts +85 -13
  113. package/src/server/index.ts +239 -97
  114. package/src/server/lifecycle.ts +155 -25
  115. package/src/server/management/agent-settings-routes.ts +75 -32
  116. package/src/server/management/api-key-usage.ts +167 -0
  117. package/src/server/management/body.ts +35 -0
  118. package/src/server/management/combo-routes.ts +5 -1
  119. package/src/server/management/config-routes.ts +37 -12
  120. package/src/server/management/logs-usage-routes.ts +41 -21
  121. package/src/server/management/model-routes.ts +173 -53
  122. package/src/server/management/oauth-account-routes.ts +115 -26
  123. package/src/server/management/provider-routes.ts +56 -6
  124. package/src/server/management/sidebar-routes.ts +50 -1
  125. package/src/server/management/system-restart.ts +13 -6
  126. package/src/server/management/system-routes.ts +15 -3
  127. package/src/server/management/usage-summary-cache.ts +86 -0
  128. package/src/server/management-api.ts +39 -5
  129. package/src/server/management-auth.ts +65 -14
  130. package/src/server/port-reclaim.ts +58 -12
  131. package/src/server/ports.ts +2 -0
  132. package/src/server/proxy-liveness.ts +51 -12
  133. package/src/server/relay-eager.ts +20 -4
  134. package/src/server/relay.ts +548 -154
  135. package/src/server/request-decompress.ts +51 -4
  136. package/src/server/request-log.ts +134 -15
  137. package/src/server/responses/collaboration.ts +15 -4
  138. package/src/server/responses/compact.ts +3 -0
  139. package/src/server/responses/core.ts +241 -66
  140. package/src/server/responses-image-gen-repair.ts +19 -5
  141. package/src/server/responses-item-id-repair.ts +23 -5
  142. package/src/server/sse-payload-rewrite.ts +71 -12
  143. package/src/server/startup-health-cache.ts +14 -1
  144. package/src/server/system-env.ts +8 -1
  145. package/src/server/windows-tcp-drop.ts +15 -5
  146. package/src/server/ws-bridge.ts +25 -0
  147. package/src/service.ts +23 -3
  148. package/src/storage/policy-job.ts +93 -23
  149. package/src/storage/policy-worker.ts +6 -0
  150. package/src/storage/restore-job.ts +62 -16
  151. package/src/storage/restore-worker.ts +6 -0
  152. package/src/storage/storage-mutation-coordinator.ts +36 -6
  153. package/src/storage/worker-lifecycle.ts +181 -47
  154. package/src/tray/windows.ts +45 -18
  155. package/src/types.ts +34 -6
  156. package/src/update/index.ts +24 -5
  157. package/src/update/job.ts +598 -73
  158. package/src/usage/log.ts +115 -17
  159. package/src/usage/summary.ts +67 -2
  160. package/src/vision/index.ts +112 -22
  161. package/src/web-search/loop.ts +38 -6
  162. package/src/web-search/progress-stream.ts +14 -3
  163. package/gui/dist/assets/index-BHsKRFh9.css +0 -1
  164. package/gui/dist/assets/index-GC0Vlu1Z.js +0 -67
package/src/codex/shim.ts CHANGED
@@ -25,12 +25,14 @@ import { serviceApiTokenFilePath } from "../lib/service-secrets";
25
25
  import { recordOwnedConfigPath } from "../lib/config-ownership";
26
26
  import { windowsEnvIndirectBatchValue } from "../lib/win-paths";
27
27
  import { isWslRuntime, wslAutomountRoot } from "./home";
28
+ import { truncateRetainedUtf8 } from "../lib/admission";
28
29
 
29
30
  const SHIM_MARKER = "opencodex codex autostart shim";
30
31
  const CODEX_SHIM_PROBE_BYTES = 16 * 1024;
31
32
  export const CODEX_SHIM_REPLACEMENT_STABLE_MS = 100;
32
33
  export const CODEX_SHIM_STATE_MAX_BYTES = 1024 * 1024;
33
34
  const CODEX_SHIM_RESTORE_LOCK_STALE_MS = 30_000;
35
+ const MAX_DIAGNOSTIC_VALUE_BYTES = 8 * 1024;
34
36
  let lastShimDiscoveryError: string | null = null;
35
37
  /** Last human-readable reason discovery returned null (exposed for doctor/tests). */
36
38
  export function lastCodexDiscoveryError(): string | null {
@@ -309,10 +311,12 @@ export function findCodexOnPath(deps: CodexPathScanDeps = {}): string | null {
309
311
  }
310
312
 
311
313
  if (skippedInterop) {
312
- lastShimDiscoveryError =
314
+ lastShimDiscoveryError = truncateRetainedUtf8(
313
315
  `Found a Windows codex at ${skippedInterop} via WSL PATH interop, but no Linux-side codex. ` +
314
316
  "Refusing to shim a Windows launcher from WSL (a WSL shim breaks Windows invocations). " +
315
- "Install codex inside WSL (npm i -g @openai/codex), or run 'ocx ensure' from Windows to shim the Windows side.";
317
+ "Install codex inside WSL (npm i -g @openai/codex), or run 'ocx ensure' from Windows to shim the Windows side.",
318
+ MAX_DIAGNOSTIC_VALUE_BYTES,
319
+ );
316
320
  }
317
321
  return null;
318
322
  }
@@ -324,9 +328,11 @@ function findWindowsCodexTargets(): ShimFileState[] | null {
324
328
  if (existsSync(exe) && !isShim(exe)) {
325
329
  try {
326
330
  if (!lstatSync(exe).isDirectory()) {
327
- lastShimDiscoveryError =
331
+ lastShimDiscoveryError = truncateRetainedUtf8(
328
332
  `Found codex.exe at ${exe}. Refusing to rename a real .exe because exact codex.exe invocations would break; ` +
329
- "install a codex.cmd/codex.ps1 launcher or use `ocx service install` for autostart.";
333
+ "install a codex.cmd/codex.ps1 launcher or use `ocx service install` for autostart.",
334
+ MAX_DIAGNOSTIC_VALUE_BYTES,
335
+ );
330
336
  return null;
331
337
  }
332
338
  } catch { /* keep scanning */ }
@@ -26,6 +26,7 @@ import { isThreadSpawnRequest } from "../server/effort-policy";
26
26
  import { PROVIDER_REGISTRY } from "../providers/registry";
27
27
  import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers";
28
28
  import { routeModel, type RouteResult } from "../router";
29
+ import { sweepExpiredOnWrite } from "../lib/state-store-sweeper";
29
30
  export const DEFAULT_SUBAGENT_MODEL_FALLBACK_POLL_MS = 60_000;
30
31
 
31
32
  type SubagentQuotaPrimeFn = (config: OcxConfig, reason: string) => Promise<void>;
@@ -245,6 +246,17 @@ export function noteSubagentModelFailure(
245
246
  reason: "quota_exhausted",
246
247
  },
247
248
  );
249
+ sweepExpiredOnWrite(now);
250
+ }
251
+
252
+ export function sweepExpiredSubagentModelHealth(now = Date.now()): number {
253
+ let removed = 0;
254
+ for (const [key, health] of modelHealth) {
255
+ if (health.unavailableUntil > now) continue;
256
+ modelHealth.delete(key);
257
+ removed += 1;
258
+ }
259
+ return removed;
248
260
  }
249
261
 
250
262
  export function resetSubagentModelFallbackStateForTests(): void {
@@ -1,5 +1,32 @@
1
1
  import type { ServerWebSocket } from "bun";
2
2
  import type { WsData } from "../server/ws-bridge";
3
+ import { createAdmissionGate, type AdmissionMetrics, type AdmissionReservation } from "../lib/admission";
4
+
5
+ export const MAX_TRACKED_CODEX_WEBSOCKETS = 128;
6
+ const websocketGate = createAdmissionGate("codex_websockets", MAX_TRACKED_CODEX_WEBSOCKETS);
7
+
8
+ export function tryReserveCodexWebSocket(): AdmissionReservation<ServerWebSocket<WsData>> | null {
9
+ const gateLease = websocketGate.tryAcquire();
10
+ if (!gateLease) return null;
11
+ let socket: ServerWebSocket<WsData> | undefined;
12
+ let active = true;
13
+ return {
14
+ bind(value) {
15
+ if (!active) return;
16
+ socket = value;
17
+ },
18
+ release() {
19
+ if (!active) return;
20
+ active = false;
21
+ socket = undefined;
22
+ gateLease.release();
23
+ },
24
+ };
25
+ }
26
+
27
+ export function codexWebSocketAdmissionMetrics(): AdmissionMetrics {
28
+ return websocketGate.metrics();
29
+ }
3
30
 
4
31
  const socketsByAccount = new Map<string, Set<ServerWebSocket<WsData>>>();
5
32
 
@@ -1,6 +1,11 @@
1
1
  import { classifyError, isCyberPolicyCode } from "../lib/errors";
2
2
  import type { OcxComboTarget } from "../types";
3
3
  import { targetKey } from "./types";
4
+ import {
5
+ captureConfigGeneration,
6
+ sweepExpiredOnWrite,
7
+ type GenerationContext,
8
+ } from "../lib/state-store-sweeper";
4
9
 
5
10
  interface TargetCooldown {
6
11
  cooldownUntil: number;
@@ -11,6 +16,8 @@ const MAX_COOLDOWN_MS = 10 * 60_000;
11
16
 
12
17
  /** Map<`${comboId}\0${provider/model}`, TargetCooldown> */
13
18
  const targetCooldowns = new Map<string, TargetCooldown>();
19
+ let lastReconciledGeneration = 0;
20
+ let liveComboTargets = new Set<string>();
14
21
 
15
22
  function cooldownMapKey(
16
23
  comboId: string,
@@ -55,20 +62,43 @@ export function isComboTargetInCooldown(
55
62
  export function coolComboTarget(
56
63
  comboId: string,
57
64
  target: Pick<OcxComboTarget, "provider" | "model">,
58
- options?: { retryAfter?: string | null; now?: number; cooldownMs?: number },
65
+ options?: { retryAfter?: string | null; now?: number; cooldownMs?: number; writerGeneration?: number },
59
66
  ): void {
60
67
  const now = options?.now ?? Date.now();
68
+ const writerGeneration = options?.writerGeneration ?? captureConfigGeneration();
69
+ const ownerKey = `${comboId}::${targetKey(target)}`;
70
+ if (writerGeneration < lastReconciledGeneration && !liveComboTargets.has(ownerKey)) return;
61
71
  const cooldownMs = options?.cooldownMs
62
72
  ?? parseRetryAfterMs(options?.retryAfter, now)
63
73
  ?? DEFAULT_COOLDOWN_MS;
64
74
  targetCooldowns.set(cooldownMapKey(comboId, target), {
65
75
  cooldownUntil: now + Math.min(Math.max(cooldownMs, 1), MAX_COOLDOWN_MS),
66
76
  });
77
+ sweepExpiredOnWrite(now);
78
+ }
79
+
80
+ export function reconcileComboTargetCooldowns(context: GenerationContext): number {
81
+ if (context.generation <= lastReconciledGeneration) return 0;
82
+ liveComboTargets = new Set(context.comboTargets);
83
+ lastReconciledGeneration = context.generation;
84
+ return 0;
85
+ }
86
+
87
+ export function sweepExpiredComboTargetCooldowns(now = Date.now()): number {
88
+ let removed = 0;
89
+ for (const [key, cooldown] of targetCooldowns) {
90
+ if (cooldown.cooldownUntil > now) continue;
91
+ targetCooldowns.delete(key);
92
+ removed += 1;
93
+ }
94
+ return removed;
67
95
  }
68
96
 
69
97
  export function clearComboTargetCooldowns(comboId?: string): void {
70
98
  if (comboId === undefined) {
71
99
  targetCooldowns.clear();
100
+ liveComboTargets.clear();
101
+ lastReconciledGeneration = 0;
72
102
  return;
73
103
  }
74
104
  const prefix = `${comboId}\0`;
@@ -2,6 +2,15 @@ import type { OcxComboDefaultEffort, OcxComboTarget, OcxConfig } from "../types"
2
2
  import { resolveComboId } from "./types";
3
3
 
4
4
  const warnedUnsupportedDefaults = new Set<string>();
5
+ let lastWarningReconciledGeneration = 0;
6
+
7
+ export function reconcileComboWarningMemos(generation: number): number {
8
+ if (generation <= lastWarningReconciledGeneration) return 0;
9
+ const removed = warnedUnsupportedDefaults.size;
10
+ warnedUnsupportedDefaults.clear();
11
+ lastWarningReconciledGeneration = generation;
12
+ return removed;
13
+ }
5
14
 
6
15
  export function resetComboEffortWarningStateForTests(): void {
7
16
  warnedUnsupportedDefaults.clear();
@@ -2,12 +2,17 @@ import type { OcxComboTarget, OcxConfig } from "../types";
2
2
  import { coolComboTarget, isComboTargetInCooldown } from "./failover";
3
3
  import { getCombo, resolveComboId, targetKey } from "./types";
4
4
  import type { NormalizedComboConfig } from "./types";
5
+ import {
6
+ captureConfigGeneration,
7
+ type GenerationContext,
8
+ } from "../lib/state-store-sweeper";
5
9
 
6
10
  export interface ComboPick {
7
11
  comboId: string;
8
12
  target: Required<OcxComboTarget>;
9
13
  targetIndex: number;
10
14
  attempted: string[];
15
+ writerGeneration: number;
11
16
  }
12
17
 
13
18
  interface SelectionState {
@@ -17,6 +22,17 @@ interface SelectionState {
17
22
  }
18
23
 
19
24
  const selectionState = new Map<string, SelectionState>();
25
+ let lastReconciledGeneration = 0;
26
+ let liveComboTargets = new Set<string>();
27
+
28
+ function comboTargetOwnerKey(comboId: string, key: string): string {
29
+ return `${comboId}::${key}`;
30
+ }
31
+
32
+ function mayCommitComboState(comboId: string, key: string, writerGeneration: number): boolean {
33
+ return writerGeneration >= lastReconciledGeneration
34
+ || liveComboTargets.has(comboTargetOwnerKey(comboId, key));
35
+ }
20
36
 
21
37
  export class UnknownComboError extends Error {
22
38
  constructor(readonly comboId: string) {
@@ -74,6 +90,7 @@ export function pickComboTarget(
74
90
  eligible?: (target: Required<OcxComboTarget>) => boolean;
75
91
  } = {},
76
92
  ): ComboPick | null {
93
+ const writerGeneration = captureConfigGeneration();
77
94
  const combo = getCombo(config, comboId);
78
95
  if (!combo) throw new UnknownComboError(comboId);
79
96
  const excluded = new Set(options.exclude ?? []);
@@ -114,6 +131,7 @@ export function pickComboTarget(
114
131
  target,
115
132
  targetIndex,
116
133
  attempted: [...excluded, targetKey(target)],
134
+ writerGeneration,
117
135
  };
118
136
  }
119
137
 
@@ -121,10 +139,13 @@ export function noteComboSuccess(
121
139
  comboId: string,
122
140
  combo: NormalizedComboConfig,
123
141
  target: Required<OcxComboTarget>,
142
+ writerGeneration = captureConfigGeneration(),
124
143
  ): void {
125
144
  if (combo.strategy !== "round-robin") return;
145
+ const key = targetKey(target);
146
+ if (!mayCommitComboState(comboId, key, writerGeneration)) return;
126
147
  const state = selectionState.get(comboId);
127
- if (!state || state.activeKey !== targetKey(target)) return;
148
+ if (!state || state.activeKey !== key) return;
128
149
  state.successes += 1;
129
150
  if (state.successes >= combo.stickyLimit) {
130
151
  delete state.activeKey;
@@ -132,7 +153,12 @@ export function noteComboSuccess(
132
153
  }
133
154
  }
134
155
 
135
- export function noteComboFailure(comboId: string, target: OcxComboTarget): void {
156
+ export function noteComboFailure(
157
+ comboId: string,
158
+ target: OcxComboTarget,
159
+ writerGeneration = captureConfigGeneration(),
160
+ ): void {
161
+ if (!mayCommitComboState(comboId, targetKey(target), writerGeneration)) return;
136
162
  const state = selectionState.get(comboId);
137
163
  if (state?.activeKey === targetKey(target)) {
138
164
  delete state.activeKey;
@@ -149,8 +175,11 @@ export function advanceComboAfterFailure(
149
175
  eligible?: (target: Required<OcxComboTarget>) => boolean;
150
176
  } = {},
151
177
  ): ComboPick | null {
152
- noteComboFailure(pick.comboId, pick.target);
153
- coolComboTarget(pick.comboId, pick.target, options);
178
+ noteComboFailure(pick.comboId, pick.target, pick.writerGeneration);
179
+ coolComboTarget(pick.comboId, pick.target, {
180
+ ...options,
181
+ writerGeneration: pick.writerGeneration,
182
+ });
154
183
  return pickComboTarget(config, pick.comboId, {
155
184
  exclude: pick.attempted,
156
185
  eligible: target => !isComboTargetInCooldown(pick.comboId, target, options.now)
@@ -158,9 +187,36 @@ export function advanceComboAfterFailure(
158
187
  });
159
188
  }
160
189
 
190
+ export function reconcileComboRotationState(context: GenerationContext): number {
191
+ if (context.generation <= lastReconciledGeneration) return 0;
192
+ let removed = 0;
193
+ for (const [comboId, state] of selectionState) {
194
+ if (!context.comboIds.has(comboId)) {
195
+ selectionState.delete(comboId);
196
+ removed += 1;
197
+ continue;
198
+ }
199
+ if (state.activeKey && !context.comboTargets.has(comboTargetOwnerKey(comboId, state.activeKey))) {
200
+ delete state.activeKey;
201
+ state.successes = 0;
202
+ removed += 1;
203
+ }
204
+ for (const key of state.currentWeights.keys()) {
205
+ if (context.comboTargets.has(comboTargetOwnerKey(comboId, key))) continue;
206
+ state.currentWeights.delete(key);
207
+ removed += 1;
208
+ }
209
+ }
210
+ liveComboTargets = new Set(context.comboTargets);
211
+ lastReconciledGeneration = context.generation;
212
+ return removed;
213
+ }
214
+
161
215
  export function clearComboSelectionState(comboId?: string): void {
162
216
  if (comboId === undefined) {
163
217
  selectionState.clear();
218
+ liveComboTargets.clear();
219
+ lastReconciledGeneration = 0;
164
220
  return;
165
221
  }
166
222
  selectionState.delete(comboId);
@@ -304,6 +304,18 @@ export function listComboIds(config: { combos?: Record<string, OcxComboConfig> }
304
304
  return Object.keys(config.combos ?? {}).sort((a, b) => a.localeCompare(b));
305
305
  }
306
306
 
307
+ export function listLiveComboTargetKeys(
308
+ config: { combos?: Record<string, OcxComboConfig> },
309
+ ): ReadonlySet<string> {
310
+ const keys = new Set<string>();
311
+ for (const id of listComboIds(config)) {
312
+ const combo = getCombo(config, id);
313
+ if (!combo) continue;
314
+ for (const target of combo.targets) keys.add(`${id}::${targetKey(target)}`);
315
+ }
316
+ return keys;
317
+ }
318
+
307
319
  export function getCombo(
308
320
  config: { combos?: Record<string, OcxComboConfig> },
309
321
  id: string,