@bitkyc08/opencodex 2.66.0 → 2.67.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 (60) hide show
  1. package/bin/ocx.mjs +24 -11
  2. package/gui/dist/assets/{App-gkoJIEOV.css → App-BJsT8Icc.css} +1 -1
  3. package/gui/dist/assets/{App-DyHlFeNB.js → App-D3pNGiN4.js} +15 -15
  4. package/gui/dist/assets/{Tray-CI8cVKWu.js → Tray-yb05wCjb.js} +1 -1
  5. package/gui/dist/assets/index-CouvvtMV.js +86 -0
  6. package/gui/dist/assets/index-DtNmX7hW.css +1 -0
  7. package/gui/dist/assets/{tray-data-_WyYzbMt.js → tray-data-CYGdjCJ7.js} +1 -1
  8. package/gui/dist/index.html +2 -2
  9. package/package.json +1 -1
  10. package/src/chat/inbound.ts +82 -3
  11. package/src/chat/outbound.ts +5 -3
  12. package/src/claude/desktop-first-party.ts +12 -3
  13. package/src/claude/first-party-settings.ts +97 -0
  14. package/src/claude/intercept/client-class.ts +18 -0
  15. package/src/claude/intercept/listener.ts +5 -0
  16. package/src/claude/intercept/runtime.ts +6 -0
  17. package/src/cli/capabilities.ts +12 -0
  18. package/src/cli/claude-desktop.ts +11 -6
  19. package/src/cli/claude.ts +25 -1
  20. package/src/cli/ensure-desired-integrations.ts +14 -1
  21. package/src/cli/integrations.ts +9 -1
  22. package/src/cli/uninstall-client-state.ts +6 -0
  23. package/src/cli/uninstall-integrations.ts +115 -0
  24. package/src/codex/history-job.ts +2 -1
  25. package/src/codex/main-account-hard-lock.ts +36 -19
  26. package/src/codex/quota.ts +11 -1
  27. package/src/combos/resolve.ts +11 -3
  28. package/src/config/load-degrade.ts +4 -0
  29. package/src/generated/compatibility-version.json +67 -43
  30. package/src/generated/worker-bundles.gen.ts +4 -0
  31. package/src/images/loop.ts +10 -3
  32. package/src/integrations/aside-profile-context.ts +17 -0
  33. package/src/lab/fabric/executor.ts +25 -3
  34. package/src/lab/fabric/producer-isolate.ts +199 -32
  35. package/src/lab/fabric/scratch.ts +9 -2
  36. package/src/lib/shadow-call.ts +11 -4
  37. package/src/lib/worker-embed.ts +33 -0
  38. package/src/oauth/generic-account-failover.ts +5 -3
  39. package/src/oauth/index.ts +19 -29
  40. package/src/oauth/store.ts +106 -34
  41. package/src/remote-control/workspace-rpc.ts +114 -19
  42. package/src/router.ts +7 -1
  43. package/src/server/chat-completions.ts +2 -1
  44. package/src/server/index/claude-intercept-lifecycle.ts +13 -0
  45. package/src/server/management/agent-settings-routes.ts +123 -5
  46. package/src/server/management/context.ts +4 -0
  47. package/src/server/management/native-integration-routes.ts +19 -8
  48. package/src/server/responses/core-normalize.ts +13 -1
  49. package/src/server/responses/request-prepare.ts +5 -3
  50. package/src/server/responses/sidecar-execution.ts +9 -2
  51. package/src/server/search.ts +28 -7
  52. package/src/storage/policy-job.ts +2 -1
  53. package/src/storage/restore-job.ts +2 -1
  54. package/src/types/config.ts +11 -5
  55. package/src/update/transactional-install.mjs +5 -1
  56. package/src/web-search/alpha-search.ts +71 -0
  57. package/src/web-search/devin-executor.ts +192 -0
  58. package/src/web-search/loop.ts +10 -3
  59. package/gui/dist/assets/index-BiwnVAUm.js +0 -86
  60. package/gui/dist/assets/index-ComVsdSk.css +0 -1
@@ -765,7 +765,7 @@ function serializeMutation<T>(work: () => Promise<T>, retainedValues: readonly u
765
765
  drainOAuthMutations();
766
766
  return result;
767
767
  }
768
- export function mutateStore<T>(fn:(store:AuthStore)=>T|Promise<T>, retainedValues: readonly unknown[] = [], options?: { waitMs?: number; assertBeforePersist?: () => void; scrubLegacyBackup?: (result: T) => readonly string[] }):Promise<T>{return serializeMutation(async()=>{const guard=await createOAuthFileLock({path:getAuthStoreLockPath(),staleAfterMs:30000}).acquire();try{
768
+ export function mutateStore<T>(fn:(store:AuthStore)=>T|Promise<T>, retainedValues: readonly unknown[] = [], options?: { waitMs?: number; assertBeforePersist?: () => void; scrubLegacyBackup?: (result: T) => readonly string[]; finalizeResult?: (result: T, store: AuthStore) => void }):Promise<T>{return serializeMutation(async()=>{const guard=await createOAuthFileLock({path:getAuthStoreLockPath(),staleAfterMs:30000}).acquire();try{
769
769
  const { store, hadLegacy } = loadAuthStoreInternal();
770
770
  if (hadLegacy) backupLegacyOnce();
771
771
  const selections = new Map(Object.entries(store).map(([provider, set]) => [provider, {
@@ -798,6 +798,9 @@ export function mutateStore<T>(fn:(store:AuthStore)=>T|Promise<T>, retainedValue
798
798
  changedProviders.push(provider);
799
799
  }
800
800
  }
801
+ // Receipt-producing mutations need the revision assigned by the bookkeeping above, not the
802
+ // provisional value visible inside their callback. Finalization cannot await or mutate disk.
803
+ options?.finalizeResult?.(result, store);
801
804
  persist(store);
802
805
  if (scrubbedProviders.length > 0) scrubLegacyBackup(scrubbedProviders);
803
806
  for (const provider of changedProviders) publishAccountSelection(provider, "oauth");
@@ -819,63 +822,132 @@ export function getCredential(provider: string): OAuthCredentials | null {
819
822
  * active slot / whole set instead. An explicit add-account login can preserve the legacy slot;
820
823
  * an identity-less credential then gets its deterministic refresh-derived account id.
821
824
  */
822
- export async function saveCredential(
825
+ export interface OAuthCredentialWriteReceipt {
826
+ provider: string;
827
+ accountId: string;
828
+ credentialGeneration: string;
829
+ selectionRevision: string | undefined;
830
+ previousActiveAccountId: string | undefined;
831
+ previousAccount: ProviderAccount | undefined;
832
+ }
833
+
834
+ export async function saveCredentialWithReceipt(
823
835
  provider: string,
824
836
  cred: OAuthCredentials,
825
837
  opts: { preserveIdentityless?: boolean; assertBeforePersist?: () => void } = {},
826
- ): Promise<void> {
838
+ ): Promise<OAuthCredentialWriteReceipt | null> {
827
839
  const safe = normalizeCredential(cred);
828
- if (!safe) return;
829
- await mutateStore(store => {
840
+ if (!safe) return null;
841
+ return await mutateStore(store => {
830
842
  const set = store[provider];
843
+ const previousActiveAccountId = set?.activeAccountId;
844
+ const previousAccounts = new Map(
845
+ set?.accounts.map(account => [account.id, structuredClone(account)]) ?? [],
846
+ );
831
847
  // Login explicitly selects an account, including a re-login to the same slot.
832
848
  if (set) set.selectionRevision = randomUUID();
833
849
  const identity = safe.accountId ?? safe.email;
850
+ let accountId: string;
834
851
  if (!set || SINGLE_SLOT_PROVIDERS.has(provider)) {
835
852
  const id = newAccountId(safe);
836
853
  store[provider] = { activeAccountId: id, accounts: [{ id, credential: safe, addedAt: Date.now() }] };
837
- return;
838
- }
839
- if (identity) {
854
+ accountId = id;
855
+ } else if (identity) {
840
856
  const existing = set.accounts.find(a => (a.credential.accountId ?? a.credential.email) === identity);
841
857
  if (existing) {
842
858
  existing.credential = safe;
843
859
  delete existing.needsReauth;
844
860
  set.activeAccountId = existing.id;
845
- return;
861
+ accountId = existing.id;
862
+ } else {
863
+ // Legacy migration: a pre-identity row (no accountId/email) for this provider is the
864
+ // SAME human re-logging in after the identity extraction shipped — upgrading the
865
+ // active identity-less row in place prevents a stale duplicate that stays selectable
866
+ // and would re-refresh into a second row with the same identity.
867
+ const active = set.accounts.find(a => a.id === set.activeAccountId);
868
+ if (!opts.preserveIdentityless && active && active.credential.accountId === undefined && active.credential.email === undefined) {
869
+ active.credential = safe;
870
+ delete active.needsReauth;
871
+ accountId = active.id;
872
+ } else {
873
+ const id = distinctAccountId(safe, set.accounts);
874
+ set.accounts.push({ id, credential: safe, addedAt: Date.now() });
875
+ set.activeAccountId = id;
876
+ accountId = id;
877
+ }
846
878
  }
847
- // Legacy migration: a pre-identity row (no accountId/email) for this provider is the
848
- // SAME human re-logging in after the identity extraction shipped — upgrading the
849
- // active identity-less row in place prevents a stale duplicate that stays selectable
850
- // and would re-refresh into a second row with the same identity.
879
+ } else if (opts.preserveIdentityless) {
880
+ const id = distinctAccountId(safe, set.accounts);
881
+ set.accounts.push({ id, credential: safe, addedAt: Date.now() });
882
+ set.activeAccountId = id;
883
+ accountId = id;
884
+ } else {
885
+ // No identity during a normal login: replace the active slot in place.
851
886
  const active = set.accounts.find(a => a.id === set.activeAccountId);
852
- if (!opts.preserveIdentityless && active && active.credential.accountId === undefined && active.credential.email === undefined) {
887
+ if (active) {
853
888
  active.credential = safe;
854
889
  delete active.needsReauth;
855
- return;
890
+ accountId = active.id;
891
+ } else {
892
+ const id = distinctAccountId(safe, set.accounts);
893
+ set.accounts.push({ id, credential: safe, addedAt: Date.now() });
894
+ set.activeAccountId = id;
895
+ accountId = id;
856
896
  }
857
- const id = distinctAccountId(safe, set.accounts);
858
- set.accounts.push({ id, credential: safe, addedAt: Date.now() });
859
- set.activeAccountId = id;
860
- return;
861
897
  }
862
- if (opts.preserveIdentityless) {
863
- const id = distinctAccountId(safe, set.accounts);
864
- set.accounts.push({ id, credential: safe, addedAt: Date.now() });
865
- set.activeAccountId = id;
866
- return;
898
+ return {
899
+ provider,
900
+ accountId,
901
+ credentialGeneration: credentialGeneration(safe),
902
+ selectionRevision: store[provider]?.selectionRevision,
903
+ previousActiveAccountId,
904
+ previousAccount: previousAccounts.get(accountId),
905
+ };
906
+ }, [provider, safe], {
907
+ assertBeforePersist: opts.assertBeforePersist,
908
+ finalizeResult: (receipt, store) => {
909
+ receipt.selectionRevision = store[provider]?.selectionRevision;
910
+ },
911
+ });
912
+ }
913
+
914
+ /** Ordinary callers do not acquire rollback authority merely by saving a credential. */
915
+ export async function saveCredential(
916
+ provider: string,
917
+ cred: OAuthCredentials,
918
+ opts: { preserveIdentityless?: boolean; assertBeforePersist?: () => void } = {},
919
+ ): Promise<void> {
920
+ await saveCredentialWithReceipt(provider, cred, opts);
921
+ }
922
+
923
+ /** Compensate one owned login write without deleting or selecting over concurrent work. */
924
+ export async function rollbackCredentialWriteIfMatch(
925
+ receipt: OAuthCredentialWriteReceipt,
926
+ ): Promise<"rolled-back" | "stale"> {
927
+ return await mutateStore(store => {
928
+ const set = store[receipt.provider];
929
+ const index = set?.accounts.findIndex(account => account.id === receipt.accountId) ?? -1;
930
+ if (!set || index < 0) return "stale" as const;
931
+ const current = set.accounts[index]!;
932
+ if (credentialGeneration(current.credential) !== receipt.credentialGeneration) return "stale" as const;
933
+ // A later explicit selection of the same account owns that choice. Do not remove or rewrite
934
+ // the selected slot underneath it; the failed login can report failure without erasing newer state.
935
+ if (set.activeAccountId === receipt.accountId
936
+ && set.selectionRevision !== receipt.selectionRevision) return "stale" as const;
937
+
938
+ if (receipt.previousAccount) set.accounts[index] = structuredClone(receipt.previousAccount);
939
+ else set.accounts.splice(index, 1);
940
+ if (set.accounts.length === 0) {
941
+ delete store[receipt.provider];
942
+ return "rolled-back" as const;
867
943
  }
868
- // No identity during a normal login: replace the active slot in place.
869
- const active = set.accounts.find(a => a.id === set.activeAccountId);
870
- if (active) {
871
- active.credential = safe;
872
- delete active.needsReauth;
873
- } else {
874
- const id = distinctAccountId(safe, set.accounts);
875
- set.accounts.push({ id, credential: safe, addedAt: Date.now() });
876
- set.activeAccountId = id;
944
+ if (set.activeAccountId === receipt.accountId) {
945
+ const previousStillExists = receipt.previousActiveAccountId
946
+ && set.accounts.some(account => account.id === receipt.previousActiveAccountId);
947
+ set.activeAccountId = previousStillExists ? receipt.previousActiveAccountId! : set.accounts[0]!.id;
877
948
  }
878
- }, [provider, safe], { assertBeforePersist: opts.assertBeforePersist });
949
+ return "rolled-back" as const;
950
+ }, [receipt]);
879
951
  }
880
952
 
881
953
  /**
@@ -16,15 +16,30 @@ import {
16
16
  frameRemoteWorkspaceRpcMessage,
17
17
  } from "./workspace-rpc-framing";
18
18
 
19
- const REMOTE_WORKSPACE_RPC_VERSION = 1 as const;
20
- const REMOTE_WORKSPACE_RPC_DEFAULT_TIMEOUT_MS = 30_000;
19
+ // Old endpoints execute request frames immediately and cannot safely participate in prepare/grant.
20
+ const REMOTE_WORKSPACE_RPC_VERSION = 2 as const;
21
+ const REMOTE_WORKSPACE_RPC_DEFAULT_TIMEOUT_MS = 65_000;
22
+ const REMOTE_WORKSPACE_RPC_MAX_TIMEOUT_MS = 120_000;
21
23
  const REMOTE_WORKSPACE_RPC_MAX_ACTIVE_REQUESTS = 8;
22
24
  interface RemoteWorkspaceRpcRequest {
23
25
  version: typeof REMOTE_WORKSPACE_RPC_VERSION;
24
- kind: "request";
26
+ kind: "prepare";
27
+ timeoutMs: number;
25
28
  request: RemoteWorkspaceExecutionRequest;
26
29
  }
27
30
 
31
+ interface RemoteWorkspaceRpcCancel {
32
+ version: typeof REMOTE_WORKSPACE_RPC_VERSION;
33
+ kind: "cancel";
34
+ requestId: string;
35
+ }
36
+
37
+ interface RemoteWorkspaceRpcGrant {
38
+ version: typeof REMOTE_WORKSPACE_RPC_VERSION;
39
+ kind: "grant";
40
+ requestId: string;
41
+ }
42
+
28
43
  interface RemoteWorkspaceRpcResponse {
29
44
  version: typeof REMOTE_WORKSPACE_RPC_VERSION;
30
45
  kind: "response";
@@ -32,7 +47,7 @@ interface RemoteWorkspaceRpcResponse {
32
47
  result: RemoteWorkspaceToolResult;
33
48
  }
34
49
 
35
- type RemoteWorkspaceRpcMessage = RemoteWorkspaceRpcRequest | RemoteWorkspaceRpcResponse;
50
+ type RemoteWorkspaceRpcMessage = RemoteWorkspaceRpcRequest | RemoteWorkspaceRpcResponse | RemoteWorkspaceRpcCancel | RemoteWorkspaceRpcGrant;
36
51
 
37
52
  interface PendingRequest {
38
53
  resolve(value: RemoteWorkspaceToolResult): void;
@@ -101,8 +116,14 @@ function parseMessage(value: Uint8Array): RemoteWorkspaceRpcMessage {
101
116
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("invalid remote workspace RPC message");
102
117
  const raw = parsed as Record<string, unknown>;
103
118
  if (raw.version !== REMOTE_WORKSPACE_RPC_VERSION) throw new Error("unsupported remote workspace RPC version");
104
- if (raw.kind === "request") {
105
- return { version: REMOTE_WORKSPACE_RPC_VERSION, kind: "request", request: parseRequest(raw.request) };
119
+ if (raw.kind === "prepare" && Number.isSafeInteger(raw.timeoutMs)
120
+ && (raw.timeoutMs as number) >= 1 && (raw.timeoutMs as number) <= REMOTE_WORKSPACE_RPC_MAX_TIMEOUT_MS) {
121
+ return {
122
+ version: REMOTE_WORKSPACE_RPC_VERSION,
123
+ kind: "prepare",
124
+ timeoutMs: raw.timeoutMs as number,
125
+ request: parseRequest(raw.request),
126
+ };
106
127
  }
107
128
  if (raw.kind === "response" && boundedIdentifier(raw.requestId)) {
108
129
  return {
@@ -112,6 +133,9 @@ function parseMessage(value: Uint8Array): RemoteWorkspaceRpcMessage {
112
133
  result: parseResult(raw.result),
113
134
  };
114
135
  }
136
+ if ((raw.kind === "cancel" || raw.kind === "grant") && boundedIdentifier(raw.requestId)) {
137
+ return { version: REMOTE_WORKSPACE_RPC_VERSION, kind: raw.kind, requestId: raw.requestId };
138
+ }
115
139
  throw new Error("invalid remote workspace RPC message kind");
116
140
  }
117
141
 
@@ -132,7 +156,8 @@ export class EncryptedRemoteWorkspaceTransport implements RemoteWorkspaceTranspo
132
156
 
133
157
  constructor(private readonly options: EncryptedRemoteWorkspaceTransportOptions) {
134
158
  this.timeoutMs = options.timeoutMs ?? REMOTE_WORKSPACE_RPC_DEFAULT_TIMEOUT_MS;
135
- if (!boundedIdentifier(options.executorDeviceId) || !Number.isSafeInteger(this.timeoutMs) || this.timeoutMs < 1) {
159
+ if (!boundedIdentifier(options.executorDeviceId) || !Number.isSafeInteger(this.timeoutMs)
160
+ || this.timeoutMs < 1 || this.timeoutMs > REMOTE_WORKSPACE_RPC_MAX_TIMEOUT_MS) {
136
161
  throw new Error("invalid encrypted remote workspace transport options");
137
162
  }
138
163
  }
@@ -150,22 +175,38 @@ export class EncryptedRemoteWorkspaceTransport implements RemoteWorkspaceTranspo
150
175
  const response = new Promise<RemoteWorkspaceToolResult>((resolve, reject) => {
151
176
  const timer = setTimeout(() => {
152
177
  this.pending.delete(request.requestId);
153
- reject(new Error("remote workspace request timed out"));
178
+ void this.sendCancellation(request.requestId);
179
+ reject(new Error("remote workspace request timed out; executor cancellation was requested"));
154
180
  }, this.timeoutMs);
155
181
  this.pending.set(request.requestId, { resolve, reject, timer });
156
182
  });
183
+ const pending = this.pending.get(request.requestId)!;
184
+ // Observe the response immediately: transport backpressure must not defer timeout delivery
185
+ // or leave a rejected response promise unobserved while a write is still waiting to settle.
186
+ void this.prepareAndGrant(request, pending);
187
+ return await response;
188
+ }
189
+
190
+ private async prepareAndGrant(request: RemoteWorkspaceExecutionRequest, pending: PendingRequest): Promise<void> {
157
191
  try {
158
192
  await this.sendMessage(encodeMessage({
159
193
  version: REMOTE_WORKSPACE_RPC_VERSION,
160
- kind: "request",
194
+ kind: "prepare",
195
+ timeoutMs: this.timeoutMs,
161
196
  request,
162
197
  }));
198
+ // Check again inside the serialized send queue: another write can delay this grant after
199
+ // prepare has settled. A request that has already timed out must never receive a grant.
200
+ await this.sendMessage(encodeMessage({
201
+ version: REMOTE_WORKSPACE_RPC_VERSION,
202
+ kind: "grant",
203
+ requestId: request.requestId,
204
+ }), () => this.pending.get(request.requestId) === pending);
163
205
  } catch {
164
206
  // A failed encrypted write consumes a directional counter. Continuing would make every
165
207
  // later frame undecryptable, so fail every pending operation instead of waiting for timeout.
166
208
  this.close("remote workspace send failed");
167
209
  }
168
- return await response;
169
210
  }
170
211
 
171
212
  receiveCiphertext(value: Uint8Array): void {
@@ -193,8 +234,9 @@ export class EncryptedRemoteWorkspaceTransport implements RemoteWorkspaceTranspo
193
234
  this.pending.clear();
194
235
  }
195
236
 
196
- private sendMessage(message: Uint8Array): Promise<void> {
237
+ private sendMessage(message: Uint8Array, shouldSend: () => boolean = () => true): Promise<void> {
197
238
  const operation = this.sendTail.then(async () => {
239
+ if (!shouldSend()) return;
198
240
  if (!this.online) throw new Error("remote workspace transport is closed");
199
241
  for (const frame of frameRemoteWorkspaceRpcMessage(message)) {
200
242
  await this.options.sendCiphertext(this.options.cipher.encrypt(frame));
@@ -203,6 +245,19 @@ export class EncryptedRemoteWorkspaceTransport implements RemoteWorkspaceTranspo
203
245
  this.sendTail = operation.catch(() => {});
204
246
  return operation;
205
247
  }
248
+
249
+ private async sendCancellation(requestId: string): Promise<void> {
250
+ try {
251
+ await this.sendMessage(encodeMessage({
252
+ version: REMOTE_WORKSPACE_RPC_VERSION,
253
+ kind: "cancel",
254
+ requestId,
255
+ }));
256
+ } catch {
257
+ // A failed encrypted write consumes the send counter; the session cannot safely continue.
258
+ this.close("remote workspace cancellation send failed");
259
+ }
260
+ }
206
261
  }
207
262
 
208
263
  export interface EncryptedRemoteWorkspaceExecutorEndpointOptions {
@@ -218,7 +273,12 @@ export interface EncryptedRemoteWorkspaceExecutorEndpointOptions {
218
273
  /** Executor-side endpoint. It accepts only authenticated, ordered E2EE session frames. */
219
274
  export class EncryptedRemoteWorkspaceExecutorEndpoint {
220
275
  private closed = false;
221
- private readonly active = new Map<string, AbortController>();
276
+ private readonly active = new Map<string, {
277
+ controller: AbortController;
278
+ timer: ReturnType<typeof setTimeout>;
279
+ request: RemoteWorkspaceExecutionRequest;
280
+ started: boolean;
281
+ }>();
222
282
  private readonly reassembler = new RemoteWorkspaceRpcReassembler();
223
283
  private sendTail: Promise<void> = Promise.resolve();
224
284
 
@@ -240,7 +300,26 @@ export class EncryptedRemoteWorkspaceExecutorEndpoint {
240
300
  const requestPlaintext = this.reassembler.accept(this.options.cipher.decrypt(value));
241
301
  if (!requestPlaintext) return;
242
302
  const message = parseMessage(requestPlaintext);
243
- if (message.kind !== "request") throw new Error("executor received a remote workspace response");
303
+ if (message.kind === "cancel") {
304
+ const active = this.active.get(message.requestId);
305
+ if (active) {
306
+ active.controller.abort();
307
+ if (!active.started) {
308
+ clearTimeout(active.timer);
309
+ this.active.delete(message.requestId);
310
+ }
311
+ }
312
+ return;
313
+ }
314
+ if (message.kind === "grant") {
315
+ const active = this.active.get(message.requestId);
316
+ if (!active || active.controller.signal.aborted) return;
317
+ if (active.started) throw new Error("duplicate remote workspace execution grant");
318
+ active.started = true;
319
+ await this.executeGranted(active.request, active.controller, active.timer);
320
+ return;
321
+ }
322
+ if (message.kind !== "prepare") throw new Error("executor received a remote workspace response");
244
323
  if (message.request.executorDeviceId !== this.options.executorDeviceId) {
245
324
  throw new Error("remote workspace encrypted request targeted another executor");
246
325
  }
@@ -255,12 +334,25 @@ export class EncryptedRemoteWorkspaceExecutorEndpoint {
255
334
  throw new Error("remote workspace executor request limit reached");
256
335
  }
257
336
  const controller = new AbortController();
258
- this.active.set(message.request.requestId, controller);
337
+ const timer = setTimeout(() => {
338
+ controller.abort();
339
+ const active = this.active.get(message.request.requestId);
340
+ if (active && !active.started) this.active.delete(message.request.requestId);
341
+ }, message.timeoutMs);
342
+ this.active.set(message.request.requestId, { controller, timer, request: message.request, started: false });
343
+ }
344
+
345
+ private async executeGranted(
346
+ request: RemoteWorkspaceExecutionRequest,
347
+ controller: AbortController,
348
+ timer: ReturnType<typeof setTimeout>,
349
+ ): Promise<void> {
259
350
  let result: RemoteWorkspaceToolResult;
260
351
  try {
261
- result = await this.options.executor.invoke(message.request, controller.signal);
352
+ result = await this.options.executor.invoke(request, controller.signal);
262
353
  } finally {
263
- this.active.delete(message.request.requestId);
354
+ clearTimeout(timer);
355
+ this.active.delete(request.requestId);
264
356
  }
265
357
  if (this.closed) return;
266
358
  let responsePlaintext: Uint8Array;
@@ -268,14 +360,14 @@ export class EncryptedRemoteWorkspaceExecutorEndpoint {
268
360
  responsePlaintext = encodeMessage({
269
361
  version: REMOTE_WORKSPACE_RPC_VERSION,
270
362
  kind: "response",
271
- requestId: message.request.requestId,
363
+ requestId: request.requestId,
272
364
  result,
273
365
  });
274
366
  } catch {
275
367
  responsePlaintext = encodeMessage({
276
368
  version: REMOTE_WORKSPACE_RPC_VERSION,
277
369
  kind: "response",
278
- requestId: message.request.requestId,
370
+ requestId: request.requestId,
279
371
  result: { ok: false, error: "remote workspace result exceeded the encrypted frame limit" },
280
372
  });
281
373
  }
@@ -286,7 +378,10 @@ export class EncryptedRemoteWorkspaceExecutorEndpoint {
286
378
  if (this.closed) return;
287
379
  this.closed = true;
288
380
  this.reassembler.clear();
289
- for (const controller of this.active.values()) controller.abort();
381
+ for (const active of this.active.values()) {
382
+ clearTimeout(active.timer);
383
+ active.controller.abort();
384
+ }
290
385
  this.active.clear();
291
386
  this.options.cipher.destroy();
292
387
  }
package/src/router.ts CHANGED
@@ -627,6 +627,7 @@ function routeModelInternal(
627
627
  bypassCombos: boolean,
628
628
  policyEvidence?: PolicyRequestEvidence,
629
629
  allowCompactionNativeFallback = false,
630
+ preview = false,
630
631
  ): RouteResult {
631
632
  const slash = modelId.indexOf("/");
632
633
  // Policy namespace is system-reserved: an explicit `policy/<id>` or a
@@ -694,7 +695,7 @@ function routeModelInternal(
694
695
  }
695
696
 
696
697
  if (!bypassCombos && !preservesPhysicalComboProvider(config)) {
697
- const combo = tryPickComboModel(config, modelId);
698
+ const combo = tryPickComboModel(config, modelId, preview);
698
699
  if (combo) {
699
700
  const concrete = `${combo.target.provider}/${combo.target.model}`;
700
701
  // The selected target is already a concrete provider/model reference. Resolve it without
@@ -894,6 +895,11 @@ export function routeModel(
894
895
  return routeWithDecisionTrace(config, modelId, route);
895
896
  }
896
897
 
898
+ /** Resolve a route for capability inspection without creating combo selection state. */
899
+ export function previewRouteModel(config: OcxConfig, modelId: string): RouteResult {
900
+ return routeWithDecisionTrace(config, modelId, routeModelInternal(config, modelId, false, undefined, false, true));
901
+ }
902
+
897
903
  /**
898
904
  * Route a client-selected compaction model. Codex may send a bare native model
899
905
  * even when its ordinary turns are configured for another provider; in that
@@ -341,7 +341,8 @@ async function handleChatCompletionsWithBudget(
341
341
  } else if (internalBody.store === undefined) {
342
342
  internalBody.store = false;
343
343
  }
344
- if (settledRoute && internalBody.reasoning !== undefined) {
344
+ if (settledRoute && !settledRoute.combo && settledRoute.routeKind !== "policy"
345
+ && internalBody.reasoning !== undefined) {
345
346
  const { stripEmptyLadderEffort, supportedLadderFor } = await import("./effort-policy");
346
347
  const ladder = supportedLadderFor({ provider: settledRoute.provider, modelId: settledRoute.modelId });
347
348
  const next = stripEmptyLadderEffort(internalBody.reasoning, ladder);
@@ -1,6 +1,10 @@
1
1
  import type { Server } from "bun";
2
+ import type { OcxConfig } from "../../types";
2
3
  import type { PickerRouteInput } from "../../claude/intercept/picker-models";
4
+ import { observeClaudeDesktopMode, type ClaudeDesktopModeObservation } from "../../claude/desktop-first-party";
5
+ import { firstPartyDesired, type ClaudeFirstPartyDesired } from "../../claude/first-party-settings";
3
6
  import {
7
+ claudeInterceptEnabled,
4
8
  startClaudeIntercept,
5
9
  type ClaudeInterceptHandle,
6
10
  type StartClaudeInterceptOptions,
@@ -19,6 +23,12 @@ export interface ClaudeInterceptLifecycle<T> {
19
23
  stop(): Promise<void>;
20
24
  }
21
25
 
26
+ export function buildInterceptDesiredClients(config: OcxConfig, observed: ClaudeDesktopModeObservation): () => ClaudeFirstPartyDesired {
27
+ return () => claudeInterceptEnabled(config)
28
+ ? firstPartyDesired(config, observed)
29
+ : { desktop: false, cli: false };
30
+ }
31
+
22
32
  /**
23
33
  * Routes for Desktop's Code-tab picker: the same inputs `/api/sync` gives the gateway profile
24
34
  * (src/server/management/config-routes.ts), read from the persisted config at call time. Dynamic
@@ -48,8 +58,11 @@ export function createClaudeInterceptLifecycle<T>(): ClaudeInterceptLifecycle<T>
48
58
  ownsListener: requestServer => listener !== null && requestServer === listener,
49
59
  start(options) {
50
60
  const dispatch = options.dispatch;
61
+ const observed = observeClaudeDesktopMode(options.config);
51
62
  pending = startClaudeIntercept<T>({
52
63
  ...options,
64
+ // A disabled Claude surface relays everything while the bound listener lives until restart.
65
+ desiredClients: buildInterceptDesiredClients(options.config, observed),
53
66
  loadPickerRoutes: options.loadPickerRoutes ?? loadPickerRoutesFromCatalog,
54
67
  dispatch: (req, requestServer) => {
55
68
  listener ??= requestServer;