@bitkyc08/opencodex 2.66.0-preview.20260925 → 2.67.0-preview.20260926
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.
- package/bin/ocx.mjs +24 -11
- package/gui/dist/assets/{App-BVjA0T6O.js → App-B3VRRYSj.js} +15 -15
- package/gui/dist/assets/{App-gkoJIEOV.css → App-BJsT8Icc.css} +1 -1
- package/gui/dist/assets/{Tray-BCFtiWwD.js → Tray-TYAA0EQY.js} +1 -1
- package/gui/dist/assets/index-9Ju7nHsu.js +86 -0
- package/gui/dist/assets/index-DtNmX7hW.css +1 -0
- package/gui/dist/assets/{tray-data-C4FiNaxk.js → tray-data-xKfS2gSG.js} +1 -1
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/chat/inbound.ts +82 -3
- package/src/chat/outbound.ts +5 -3
- package/src/claude/desktop-first-party.ts +12 -3
- package/src/claude/first-party-settings.ts +97 -0
- package/src/claude/intercept/client-class.ts +18 -0
- package/src/claude/intercept/listener.ts +5 -0
- package/src/claude/intercept/runtime.ts +6 -0
- package/src/cli/capabilities.ts +12 -0
- package/src/cli/claude-desktop.ts +11 -6
- package/src/cli/claude.ts +25 -1
- package/src/cli/ensure-desired-integrations.ts +14 -1
- package/src/cli/integrations.ts +9 -1
- package/src/cli/uninstall-client-state.ts +6 -0
- package/src/cli/uninstall-integrations.ts +115 -0
- package/src/codex/history-job.ts +2 -1
- package/src/codex/main-account-hard-lock.ts +36 -19
- package/src/codex/quota.ts +11 -1
- package/src/combos/resolve.ts +11 -3
- package/src/config/load-degrade.ts +4 -0
- package/src/generated/compatibility-version.json +67 -43
- package/src/generated/worker-bundles.gen.ts +4 -0
- package/src/images/loop.ts +10 -3
- package/src/integrations/aside-profile-context.ts +17 -0
- package/src/lab/fabric/executor.ts +25 -3
- package/src/lab/fabric/producer-isolate.ts +199 -32
- package/src/lab/fabric/scratch.ts +9 -2
- package/src/lib/shadow-call.ts +11 -4
- package/src/lib/worker-embed.ts +33 -0
- package/src/oauth/generic-account-failover.ts +5 -3
- package/src/oauth/index.ts +19 -29
- package/src/oauth/store.ts +106 -34
- package/src/remote-control/workspace-rpc.ts +114 -19
- package/src/router.ts +7 -1
- package/src/server/chat-completions.ts +2 -1
- package/src/server/index/claude-intercept-lifecycle.ts +13 -0
- package/src/server/management/agent-settings-routes.ts +123 -5
- package/src/server/management/context.ts +4 -0
- package/src/server/management/native-integration-routes.ts +19 -8
- package/src/server/responses/core-normalize.ts +13 -1
- package/src/server/responses/request-prepare.ts +5 -3
- package/src/server/responses/sidecar-execution.ts +9 -2
- package/src/server/search.ts +28 -7
- package/src/storage/policy-job.ts +2 -1
- package/src/storage/restore-job.ts +2 -1
- package/src/types/config.ts +11 -5
- package/src/update/transactional-install.mjs +5 -1
- package/src/web-search/alpha-search.ts +71 -0
- package/src/web-search/devin-executor.ts +192 -0
- package/src/web-search/loop.ts +10 -3
- package/gui/dist/assets/index-ComVsdSk.css +0 -1
- package/gui/dist/assets/index-vO4dgfiU.js +0 -86
package/src/cli/integrations.ts
CHANGED
|
@@ -20,7 +20,8 @@ const CLAUDE_USAGE = `Usage:
|
|
|
20
20
|
[--compact-window <tokens|default>] [--inject-agents <on|off>]
|
|
21
21
|
[--small-fast-model <id|->] [--model-map <from=to,from=to|->]
|
|
22
22
|
[--blocked-skills <name,name|->] [--web-model <id|->] [--web-backend <openai|anthropic|xai|gemini|exa|->]
|
|
23
|
-
[--vision-model <id|->] [--vision-backend <openai|anthropic|->] [--json]
|
|
23
|
+
[--vision-model <id|->] [--vision-backend <openai|anthropic|->] [--json]
|
|
24
|
+
ocx claude config set --first-party <on|off> [--json]`;
|
|
24
25
|
|
|
25
26
|
const GROK_USAGE = `Usage:
|
|
26
27
|
ocx grok [status] [--json]
|
|
@@ -74,6 +75,7 @@ export async function handleClaudeConfigCommand(argv: string[], deps: RuntimeApi
|
|
|
74
75
|
if (action !== "set") throw new CliUsageError(`unknown Claude config command ${action}`, CLAUDE_USAGE);
|
|
75
76
|
const body: Record<string, unknown> = {};
|
|
76
77
|
const enabled = takeBooleanOption(args, "--enabled");
|
|
78
|
+
const firstParty = takeBooleanOption(args, "--first-party");
|
|
77
79
|
const authMode = takeOption(args, "--auth-mode");
|
|
78
80
|
const systemEnv = takeBooleanOption(args, "--system-env");
|
|
79
81
|
const fastMode = takeBooleanOption(args, "--fast-mode");
|
|
@@ -116,6 +118,12 @@ export async function handleClaudeConfigCommand(argv: string[], deps: RuntimeApi
|
|
|
116
118
|
const vision = sidecar(visionModel, visionBackend);
|
|
117
119
|
if (web) body.webSearchSidecar = web;
|
|
118
120
|
if (vision) body.visionSidecar = vision;
|
|
121
|
+
if (firstParty !== undefined) {
|
|
122
|
+
if (Object.keys(body).length > 0) {
|
|
123
|
+
throw new CliUsageError("--first-party must be set on its own (it writes Claude Code's settings file immediately)", CLAUDE_USAGE);
|
|
124
|
+
}
|
|
125
|
+
body.cliFirstParty = firstParty;
|
|
126
|
+
}
|
|
119
127
|
if (Object.keys(body).length === 0) throw new CliUsageError("at least one Claude setting is required", CLAUDE_USAGE);
|
|
120
128
|
const result = await runtimeRequest("/api/claude-code", { method: "PUT", body: JSON.stringify(body) }, deps);
|
|
121
129
|
printData(result, wantsJson, ["Claude Code settings updated."]);
|
|
@@ -6,6 +6,7 @@ import { inspectRemoteDesktopCleanup, readDesktopDisconnectReceipt } from "../cl
|
|
|
6
6
|
import { removeOwnedConfigState, type ConfigRemovalResult } from "../lib/config-ownership";
|
|
7
7
|
import { windowsSecretAclReapPendingAtOrBelow } from "../lib/windows-secret-acl";
|
|
8
8
|
import { sharedTeardownAuthorized, type UninstallObservation } from "./uninstall-plan";
|
|
9
|
+
import { cleanupOwnedIntegrationsBeforeUninstall } from "./uninstall-integrations";
|
|
9
10
|
|
|
10
11
|
export interface UninstallClientStateDeps {
|
|
11
12
|
readConnection: typeof readClientConnectionState;
|
|
@@ -13,6 +14,7 @@ export interface UninstallClientStateDeps {
|
|
|
13
14
|
readReceipt: typeof readDesktopDisconnectReceipt;
|
|
14
15
|
disconnect: (options?: Parameters<typeof disconnectClient>[0]) => Promise<unknown>;
|
|
15
16
|
withLifecycle: typeof withClientLifecycle;
|
|
17
|
+
cleanupIntegrations: typeof cleanupOwnedIntegrationsBeforeUninstall;
|
|
16
18
|
remove: () => ConfigRemovalResult;
|
|
17
19
|
/** True while a timed-out icacls child still owns a path at or below the config directory. */
|
|
18
20
|
aclReapPending: (rootPath: string) => boolean;
|
|
@@ -24,6 +26,7 @@ const defaults: UninstallClientStateDeps = {
|
|
|
24
26
|
readReceipt: readDesktopDisconnectReceipt,
|
|
25
27
|
disconnect: options => disconnectClient(options),
|
|
26
28
|
withLifecycle: withClientLifecycle,
|
|
29
|
+
cleanupIntegrations: cleanupOwnedIntegrationsBeforeUninstall,
|
|
27
30
|
remove: () => removeOwnedConfigState(getConfigDir()),
|
|
28
31
|
aclReapPending: rootPath => windowsSecretAclReapPendingAtOrBelow(rootPath),
|
|
29
32
|
};
|
|
@@ -85,6 +88,9 @@ export async function removeOwnedConfigAfterDesktopCleanup(
|
|
|
85
88
|
if (deps.aclReapPending(getConfigDir())) {
|
|
86
89
|
throw new Error("Client cleanup refused: ACL hardening still owns a path under the config directory.");
|
|
87
90
|
}
|
|
91
|
+
// Integration records are the authority that permits removing only OpenCodex-owned fragments
|
|
92
|
+
// from third-party files. Delete them only after every recorded contribution is retired.
|
|
93
|
+
await deps.cleanupIntegrations();
|
|
88
94
|
return deps.remove();
|
|
89
95
|
});
|
|
90
96
|
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { ExportModel } from "../clients/config-export";
|
|
2
|
+
import { guardAsideProfileIO, listAsideProfiles } from "../clients/aside-profiles";
|
|
3
|
+
import { loadConfig } from "../config";
|
|
4
|
+
import { listAsideProfileStores } from "../integrations/aside-profile-context";
|
|
5
|
+
import { isIntegrationClientId, type IntegrationClientId } from "../integrations/registry";
|
|
6
|
+
import { createIntegrationStateStore, type IntegrationStateStore } from "../integrations/store";
|
|
7
|
+
import { disableIntegrationCoordinated, type WriteOutcome } from "../integrations/writer";
|
|
8
|
+
import { loadExportModels } from "../server/management/model-rows";
|
|
9
|
+
import type { OcxConfig } from "../types";
|
|
10
|
+
|
|
11
|
+
export interface UninstallIntegrationCleanupDeps {
|
|
12
|
+
createStore: () => IntegrationStateStore;
|
|
13
|
+
loadConfig: () => OcxConfig;
|
|
14
|
+
loadModels: (config: OcxConfig) => Promise<ExportModel[]>;
|
|
15
|
+
disable: (input: Parameters<typeof disableIntegrationCoordinated>[0]) => Promise<WriteOutcome>;
|
|
16
|
+
env?: NodeJS.ProcessEnv;
|
|
17
|
+
home?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const defaults: UninstallIntegrationCleanupDeps = {
|
|
21
|
+
createStore: () => createIntegrationStateStore(),
|
|
22
|
+
loadConfig,
|
|
23
|
+
loadModels: config => loadExportModels(config),
|
|
24
|
+
disable: input => disableIntegrationCoordinated(input),
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export interface UninstallIntegrationCleanupResult {
|
|
28
|
+
attempted: number;
|
|
29
|
+
changed: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Remove every contribution we can still prove we own before uninstall deletes that proof.
|
|
34
|
+
* A single refusal aborts config removal: preserving recovery state is safer than leaving an
|
|
35
|
+
* external client pointed at a proxy that no longer exists.
|
|
36
|
+
*/
|
|
37
|
+
export async function cleanupOwnedIntegrationsBeforeUninstall(
|
|
38
|
+
deps: UninstallIntegrationCleanupDeps = defaults,
|
|
39
|
+
): Promise<UninstallIntegrationCleanupResult> {
|
|
40
|
+
const store = deps.createStore();
|
|
41
|
+
const records = store.readRecordsStrict();
|
|
42
|
+
const rawIds = Object.keys(records);
|
|
43
|
+
for (const id of rawIds) {
|
|
44
|
+
if (!isIntegrationClientId(id)) {
|
|
45
|
+
throw new Error(`integration cleanup refused: ownership names unknown client ${id}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
type CleanupTarget = Pick<Parameters<typeof disableIntegrationCoordinated>[0], "clientId" | "store" | "io" | "resolvedPaths">
|
|
49
|
+
& { store: IntegrationStateStore; profileId?: number };
|
|
50
|
+
const targets: CleanupTarget[] = (rawIds as IntegrationClientId[]).sort()
|
|
51
|
+
.map(clientId => ({ clientId, store }));
|
|
52
|
+
// Read every child before any mutation. A broken child must not look like no ownership.
|
|
53
|
+
for (const child of listAsideProfileStores(store)) {
|
|
54
|
+
const childRecords = child.store.readRecordsStrict();
|
|
55
|
+
if (Object.keys(childRecords).some(id => id !== "aside")) {
|
|
56
|
+
throw new Error("integration cleanup refused: Aside profile storage names another client");
|
|
57
|
+
}
|
|
58
|
+
if (childRecords.aside) targets.push({ clientId: "aside", ...child });
|
|
59
|
+
}
|
|
60
|
+
if (targets.length === 0) return { attempted: 0, changed: 0 };
|
|
61
|
+
|
|
62
|
+
const asideTargets = targets.filter(target => target.clientId === "aside");
|
|
63
|
+
if (asideTargets.length > 0) {
|
|
64
|
+
const profiles = listAsideProfiles(deps.env, deps.home);
|
|
65
|
+
const claimedProfiles = new Set<number>();
|
|
66
|
+
for (const target of asideTargets) {
|
|
67
|
+
const record = target.store.readRecordsStrict().aside;
|
|
68
|
+
const profile = profiles.find(candidate => candidate.configPath === record?.configPath
|
|
69
|
+
&& (target.profileId === undefined || target.profileId === candidate.id));
|
|
70
|
+
if (!profile || claimedProfiles.has(profile.id)) {
|
|
71
|
+
throw new Error("integration cleanup refused: Aside profile ownership is missing or mismatched");
|
|
72
|
+
}
|
|
73
|
+
claimedProfiles.add(profile.id);
|
|
74
|
+
target.resolvedPaths = { configPath: profile.configPath, detectDir: profile.detectDir };
|
|
75
|
+
// io() closes over the raw store; route bookkeeping back through its guarded facade.
|
|
76
|
+
target.io = guardAsideProfileIO(profile, {
|
|
77
|
+
...target.store.io(),
|
|
78
|
+
appendJournal: entry => target.store.appendJournal(entry),
|
|
79
|
+
putRecord: record => target.store.putRecord(record),
|
|
80
|
+
dropRecord: clientId => target.store.dropRecord(clientId),
|
|
81
|
+
}, profiles);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const config = deps.loadConfig();
|
|
86
|
+
const models = await deps.loadModels(config);
|
|
87
|
+
let changed = 0;
|
|
88
|
+
for (const { clientId, store: targetStore, io, resolvedPaths } of targets) {
|
|
89
|
+
const result = await deps.disable({
|
|
90
|
+
clientId,
|
|
91
|
+
models,
|
|
92
|
+
config,
|
|
93
|
+
port: config.port,
|
|
94
|
+
store: targetStore,
|
|
95
|
+
...(io ? { io } : {}),
|
|
96
|
+
...(resolvedPaths ? { resolvedPaths } : {}),
|
|
97
|
+
...(deps.env ? { env: deps.env } : {}),
|
|
98
|
+
...(deps.home ? { home: deps.home } : {}),
|
|
99
|
+
});
|
|
100
|
+
if (!result.ok) {
|
|
101
|
+
throw new Error(`integration cleanup refused for ${clientId}: ${result.message}`
|
|
102
|
+
+ (result.residual ? "; recovery did not complete; inspect the client file and retained snapshots before retrying" : ""));
|
|
103
|
+
}
|
|
104
|
+
if (targetStore.readRecordsStrict()[clientId]) {
|
|
105
|
+
throw new Error(`integration cleanup did not retire ownership for ${clientId}`);
|
|
106
|
+
}
|
|
107
|
+
if (result.changed) changed++;
|
|
108
|
+
}
|
|
109
|
+
// A profile can gain ownership while catalog loading or an earlier disable awaited its lock.
|
|
110
|
+
if ([store, ...listAsideProfileStores(store).map(child => child.store)]
|
|
111
|
+
.some(current => Object.keys(current.readRecordsStrict()).length > 0)) {
|
|
112
|
+
throw new Error("integration cleanup refused: ownership changed before removal");
|
|
113
|
+
}
|
|
114
|
+
return { attempted: targets.length, changed };
|
|
115
|
+
}
|
package/src/codex/history-job.ts
CHANGED
|
@@ -24,6 +24,7 @@ import type {
|
|
|
24
24
|
HistoryWorkerResult,
|
|
25
25
|
} from "./history-worker";
|
|
26
26
|
import { currentHistoryDbBusyTimeoutMs, resolveExistingHistoryBackupPath } from "./history-provider";
|
|
27
|
+
import { spawnWorker } from "../lib/worker-embed";
|
|
27
28
|
import type { CodexHistoryFailureReason, CodexHistoryVerifiedNoopProof } from "./history-provider";
|
|
28
29
|
import { getCodexHome, resolveCodexStateDbPath } from "./paths";
|
|
29
30
|
|
|
@@ -345,7 +346,7 @@ export async function runCodexHistoryJob(
|
|
|
345
346
|
|
|
346
347
|
let worker: Worker;
|
|
347
348
|
try {
|
|
348
|
-
worker =
|
|
349
|
+
worker = spawnWorker(new URL("./history-worker.ts", import.meta.url).href, "history-worker");
|
|
349
350
|
} catch (error) {
|
|
350
351
|
return {
|
|
351
352
|
kind: "failed",
|
|
@@ -31,6 +31,30 @@ function resetTimestamp(value: number | undefined): number | undefined {
|
|
|
31
31
|
return value < 10_000_000_000 ? value * 1000 : value;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
type WindowReading = { percent: number | undefined; resetAt: number | undefined };
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The windows that govern the lock. The 5h and weekly windows each govern on their own, so either
|
|
38
|
+
* one at 98% blocks even while the other has headroom. Monthly governs only a monthly-only account:
|
|
39
|
+
* supplementary monthly data on a 5h/weekly account never becomes a lock. The list is never empty,
|
|
40
|
+
* so a record with no readings at all classifies as unknown rather than vacuously ready.
|
|
41
|
+
*/
|
|
42
|
+
function governingWindows(quota: NonNullable<ReturnType<typeof getMainPolicyQuota>>): WindowReading[] {
|
|
43
|
+
const hasShort = quota.shortPercent !== undefined || quota.shortResetAt !== undefined
|
|
44
|
+
|| quota.shortWindowSeconds !== undefined;
|
|
45
|
+
const hasWeekly = quota.weeklyPercent !== undefined || quota.weeklyResetAt !== undefined;
|
|
46
|
+
const windows: WindowReading[] = [];
|
|
47
|
+
if (hasShort) windows.push({ percent: quota.shortPercent, resetAt: quota.shortResetAt });
|
|
48
|
+
if (hasWeekly) windows.push({ percent: quota.weeklyPercent, resetAt: quota.weeklyResetAt });
|
|
49
|
+
if (windows.length === 0) windows.push({ percent: quota.monthlyPercent, resetAt: quota.monthlyResetAt });
|
|
50
|
+
return windows;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function validPercent(percent: number | undefined): percent is number {
|
|
54
|
+
// The routing score's unknown sentinel is 101. It is never a raw quota observation.
|
|
55
|
+
return typeof percent === "number" && Number.isFinite(percent) && percent >= 0 && percent <= 100;
|
|
56
|
+
}
|
|
57
|
+
|
|
34
58
|
/** Observed admission policy, not a reservation of the account's remaining quota. */
|
|
35
59
|
export function getMainAccountHardLockStatus(
|
|
36
60
|
config: PolicyConfig,
|
|
@@ -39,26 +63,19 @@ export function getMainAccountHardLockStatus(
|
|
|
39
63
|
if (!isMainAccountHardLockEnabled(config)) return { enabled: false, state: "off" };
|
|
40
64
|
const quota = getMainPolicyQuota();
|
|
41
65
|
if (!quota) return { enabled: true, state: "unknown" };
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
// The routing score's unknown sentinel is 101. It is never a raw quota observation.
|
|
52
|
-
if (typeof percent !== "number" || !Number.isFinite(percent) || percent < 0 || percent > 100) {
|
|
53
|
-
return { enabled: true, state: "unknown" };
|
|
66
|
+
const windows = governingWindows(quota);
|
|
67
|
+
const blocking = windows.filter(w => validPercent(w.percent) && w.percent >= MAIN_ACCOUNT_HARD_LOCK_PERCENT);
|
|
68
|
+
if (blocking.length > 0) {
|
|
69
|
+
// The lock holds until every blocking window reads lower, so the earliest possible unlock is
|
|
70
|
+
// the latest blocking reset. One blocking window without a future reset makes it unknowable.
|
|
71
|
+
// A predicted reset is not evidence of recovery either way: only a fresh lower reading releases.
|
|
72
|
+
const resets = blocking.map(w => resetTimestamp(w.resetAt));
|
|
73
|
+
const resetAt = resets.every(r => r !== undefined && r > now) ? Math.max(...(resets as number[])) : undefined;
|
|
74
|
+
return { enabled: true, state: "blocked", ...(resetAt !== undefined ? { resetAt } : {}) };
|
|
54
75
|
}
|
|
55
|
-
|
|
56
|
-
return {
|
|
57
|
-
|
|
58
|
-
state: "blocked",
|
|
59
|
-
// A predicted reset is not evidence of recovery. Only a fresh lower reading releases.
|
|
60
|
-
...(resetAt !== undefined && resetAt > now ? { resetAt } : {}),
|
|
61
|
-
};
|
|
76
|
+
// Unknown admits, so an unreadable window never hides a blocking one and never blocks alone.
|
|
77
|
+
if (windows.some(w => !validPercent(w.percent))) return { enabled: true, state: "unknown" };
|
|
78
|
+
return { enabled: true, state: "ready" };
|
|
62
79
|
}
|
|
63
80
|
|
|
64
81
|
export function isMainAccountHardLocked(config: PolicyConfig, now = Date.now()): boolean {
|
package/src/codex/quota.ts
CHANGED
|
@@ -354,7 +354,17 @@ function mergeAccountQuota(
|
|
|
354
354
|
return stampCodexQuotaUsageObservation(next, quota, existing);
|
|
355
355
|
}
|
|
356
356
|
|
|
357
|
-
|
|
357
|
+
const existingWeeklyPercent = existing?.weeklyPercent;
|
|
358
|
+
// A reset-only weekly observation is not a lower reading. Policy mode keeps a blocking weekly
|
|
359
|
+
// tuple whole, exactly like preserveKnownShort; a governing monthly-primary observation still
|
|
360
|
+
// replaces it, so that release path does not depend on this guard.
|
|
361
|
+
const preserveKnownWeekly = policyEvidence
|
|
362
|
+
&& quota.weeklyPercent === undefined
|
|
363
|
+
&& quota.monthlyIsPrimaryWindow !== true
|
|
364
|
+
&& finitePercent(existingWeeklyPercent)
|
|
365
|
+
&& existingWeeklyPercent >= MAIN_ACCOUNT_HARD_LOCK_PERCENT
|
|
366
|
+
&& existingWeeklyPercent <= 100;
|
|
367
|
+
if (snapshotHasWeekly(quota) && !preserveKnownWeekly) {
|
|
358
368
|
if (quota.weeklyPercent !== undefined) next.weeklyPercent = quota.weeklyPercent;
|
|
359
369
|
if (quota.weeklyResetAt !== undefined) next.weeklyResetAt = quota.weeklyResetAt;
|
|
360
370
|
} else if (snapshotHasMonthly(quota)
|
package/src/combos/resolve.ts
CHANGED
|
@@ -204,6 +204,8 @@ export function pickComboTarget(
|
|
|
204
204
|
exclude?: Iterable<string>;
|
|
205
205
|
eligible?: (target: NormalizedComboTarget) => boolean;
|
|
206
206
|
now?: number;
|
|
207
|
+
/** Inspect a round-robin choice without mutating its sticky/weight state. */
|
|
208
|
+
preview?: boolean;
|
|
207
209
|
} = {},
|
|
208
210
|
): ComboPick | null {
|
|
209
211
|
const writerGeneration = captureConfigGeneration();
|
|
@@ -222,7 +224,13 @@ export function pickComboTarget(
|
|
|
222
224
|
let state = selectionState.get(comboId);
|
|
223
225
|
if (!state) {
|
|
224
226
|
state = { successes: 0, currentWeights: new Map(), successfulUses: new Map() };
|
|
225
|
-
selectionState.set(comboId, state);
|
|
227
|
+
if (!options.preview) selectionState.set(comboId, state);
|
|
228
|
+
} else if (options.preview) {
|
|
229
|
+
state = {
|
|
230
|
+
...state,
|
|
231
|
+
currentWeights: new Map(state.currentWeights),
|
|
232
|
+
successfulUses: new Map(state.successfulUses),
|
|
233
|
+
};
|
|
226
234
|
}
|
|
227
235
|
if (state.activeKey) {
|
|
228
236
|
targetIndex = combo.targets.findIndex(target => targetKey(target) === state.activeKey && eligible(target));
|
|
@@ -542,11 +550,11 @@ export function clearComboSelectionState(comboId?: string): void {
|
|
|
542
550
|
selectionState.delete(comboId);
|
|
543
551
|
}
|
|
544
552
|
|
|
545
|
-
export function tryPickComboModel(config: OcxConfig, modelId: string): ComboPick | null {
|
|
553
|
+
export function tryPickComboModel(config: OcxConfig, modelId: string, preview = false): ComboPick | null {
|
|
546
554
|
const comboId = resolveComboId(config, modelId);
|
|
547
555
|
if (!comboId) return null;
|
|
548
556
|
if (!getCombo(config, comboId)) throw new UnknownComboError(comboId);
|
|
549
|
-
const picked = pickComboTarget(config, comboId);
|
|
557
|
+
const picked = pickComboTarget(config, comboId, { preview });
|
|
550
558
|
if (!picked) throw new NoAvailableComboTargetsError(comboId);
|
|
551
559
|
return picked;
|
|
552
560
|
}
|
|
@@ -544,6 +544,10 @@ export function normalizePersistedClaudeCode(claudeCode: unknown): OcxConfig["cl
|
|
|
544
544
|
return claudeCode as OcxConfig["claudeCode"];
|
|
545
545
|
}
|
|
546
546
|
const normalized = { ...claudeCode } as Record<string, unknown>;
|
|
547
|
+
// A malformed hand edit must not arm CLI interception or discard the whole config.
|
|
548
|
+
if (Object.hasOwn(normalized, "cliFirstParty") && typeof normalized.cliFirstParty !== "boolean") {
|
|
549
|
+
delete normalized.cliFirstParty;
|
|
550
|
+
}
|
|
547
551
|
if (Object.hasOwn(normalized, "subagentEffort") && !isClaudeSubagentEffort(normalized.subagentEffort)) {
|
|
548
552
|
delete normalized.subagentEffort;
|
|
549
553
|
}
|