@bitkyc08/opencodex 2.43.0 → 2.44.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 (138) hide show
  1. package/README.md +25 -17
  2. package/gui/dist/assets/index-B7_K1Hsj.js +115 -0
  3. package/gui/dist/assets/index-ltx3L-WS.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/command-code.ts +23 -2
  7. package/src/adapters/cursor/native-exec.ts +63 -11
  8. package/src/adapters/cursor/tool-definitions.ts +1 -1
  9. package/src/adapters/cursor/tool-schemas.ts +59 -0
  10. package/src/adapters/kiro.ts +54 -8
  11. package/src/adapters/openai-chat.ts +23 -6
  12. package/src/adapters/openai-responses.ts +3 -1
  13. package/src/adapters/opencode-go.ts +35 -0
  14. package/src/claude/agents-inject.ts +12 -5
  15. package/src/claude/desktop-3p-library.ts +89 -0
  16. package/src/claude/desktop-3p.ts +166 -95
  17. package/src/claude/desktop-discovery-inputs.ts +44 -0
  18. package/src/claude/desktop-profile.ts +1 -1
  19. package/src/claude/desktop-remote-store-artifact.ts +185 -0
  20. package/src/claude/desktop-remote-store-io.ts +91 -0
  21. package/src/claude/desktop-remote-store-state.ts +127 -0
  22. package/src/claude/desktop-remote-store.ts +269 -0
  23. package/src/claude/inbound-model-options.ts +10 -2
  24. package/src/claude/inbound-records.ts +7 -0
  25. package/src/claude/inbound.ts +1 -1
  26. package/src/claude/model-info.ts +16 -3
  27. package/src/cli/account-api.ts +7 -1
  28. package/src/cli/aside-profiles.ts +17 -0
  29. package/src/cli/capabilities.ts +39 -0
  30. package/src/cli/claude-agent-startup-sync.ts +3 -1
  31. package/src/cli/claude-desktop.ts +127 -18
  32. package/src/cli/connect.ts +27 -14
  33. package/src/cli/dispatch.ts +30 -16
  34. package/src/cli/export-command.ts +3 -1
  35. package/src/cli/index.ts +28 -16
  36. package/src/cli/integrations.ts +46 -12
  37. package/src/cli/opencode.ts +11 -4
  38. package/src/cli/runtime-api.ts +7 -2
  39. package/src/cli/uninstall-client-state.ts +78 -0
  40. package/src/client/connect.ts +476 -223
  41. package/src/client/hub-client.ts +81 -0
  42. package/src/client/hub-relay.ts +38 -6
  43. package/src/client/lifecycle-lock.ts +129 -0
  44. package/src/client/state.ts +71 -42
  45. package/src/clients/aside-profiles.ts +224 -0
  46. package/src/codex/account-lifecycle.ts +5 -9
  47. package/src/codex/auth-api.ts +50 -4
  48. package/src/codex/catalog/metadata.ts +0 -2
  49. package/src/codex/catalog/provider-fetch.ts +7 -5
  50. package/src/codex/catalog/sync.ts +88 -24
  51. package/src/codex/convergence.ts +2 -0
  52. package/src/codex/inject.ts +45 -14
  53. package/src/codex/journal.ts +6 -6
  54. package/src/codex/quota-refresh-outcome.ts +27 -0
  55. package/src/codex/shim.ts +7 -1
  56. package/src/combos/resolve.ts +10 -7
  57. package/src/config.ts +18 -5
  58. package/src/generated/compatibility-version.json +209 -105
  59. package/src/images/loop.ts +5 -2
  60. package/src/integrations/aside-profile-context.ts +270 -0
  61. package/src/integrations/aside-profile-journal.ts +229 -0
  62. package/src/integrations/aside-profiles.ts +176 -0
  63. package/src/integrations/catalog-refresh.ts +40 -0
  64. package/src/integrations/config-io.ts +16 -1
  65. package/src/integrations/owned-refresh.ts +14 -2
  66. package/src/integrations/state.ts +3 -1
  67. package/src/integrations/writer.ts +3 -1
  68. package/src/lab/fabric/producer-isolate.ts +84 -46
  69. package/src/lib/account-selection-events.ts +32 -0
  70. package/src/lib/errors.ts +9 -1
  71. package/src/lib/provider-outbound.ts +2 -45
  72. package/src/lib/proxy-env.ts +67 -0
  73. package/src/oauth/account-quota-rank.ts +9 -1
  74. package/src/oauth/anthropic-routing.ts +91 -23
  75. package/src/oauth/generic-account-failover.ts +15 -53
  76. package/src/oauth/index.ts +67 -68
  77. package/src/oauth/pool-settings-capability.ts +2 -1
  78. package/src/oauth/store.ts +91 -7
  79. package/src/oauth/types.ts +8 -0
  80. package/src/providers/api-key-selection.ts +110 -0
  81. package/src/providers/api-keys.ts +38 -31
  82. package/src/providers/context-cap.ts +27 -3
  83. package/src/providers/key-failover.ts +27 -17
  84. package/src/providers/provider-id-rewrite.ts +10 -8
  85. package/src/providers/registry.ts +1 -0
  86. package/src/responses/custom-tool-compat.ts +10 -0
  87. package/src/responses/function-call-compat.ts +173 -0
  88. package/src/responses/namespace-tool-compat.ts +27 -4
  89. package/src/responses/parser.ts +8 -0
  90. package/src/responses/task-input.ts +36 -0
  91. package/src/responses/tool-name-aliases.ts +79 -0
  92. package/src/router.ts +2 -0
  93. package/src/routing/capability.ts +10 -3
  94. package/src/routing/compatibility/assemble.ts +16 -1
  95. package/src/routing/evaluator.ts +5 -1
  96. package/src/server/auth-cors.ts +3 -0
  97. package/src/server/chat-native.ts +44 -9
  98. package/src/server/claude-messages.ts +71 -36
  99. package/src/server/grok-responses-snapshot-repair.ts +333 -0
  100. package/src/server/index.ts +26 -10
  101. package/src/server/management/account-selection-stream.ts +70 -0
  102. package/src/server/management/aside-profile-routes.ts +231 -0
  103. package/src/server/management/config-routes.ts +36 -41
  104. package/src/server/management/context.ts +3 -0
  105. package/src/server/management/integration-routes.ts +61 -3
  106. package/src/server/management/logs-usage-routes.ts +1 -0
  107. package/src/server/management/model-routes.ts +20 -9
  108. package/src/server/management/model-rows.ts +10 -2
  109. package/src/server/management/oauth-account-routes.ts +7 -0
  110. package/src/server/management/provider-routes.ts +10 -8
  111. package/src/server/management/route-registry.ts +20 -2
  112. package/src/server/management-auth.ts +16 -0
  113. package/src/server/relay-eager.ts +17 -5
  114. package/src/server/relay.ts +43 -4
  115. package/src/server/request-log.ts +29 -1
  116. package/src/server/responses/agent-task-recovery-cache.ts +11 -0
  117. package/src/server/responses/agent-task-recovery.ts +24 -4
  118. package/src/server/responses/codex-ws-pool.ts +4 -4
  119. package/src/server/responses/codex-ws-session.ts +2 -2
  120. package/src/server/responses/collaboration.ts +2 -1
  121. package/src/server/responses/combo-stream-preflight.ts +26 -7
  122. package/src/server/responses/core.ts +668 -151
  123. package/src/server/responses/fetch-helpers.ts +6 -1
  124. package/src/server/responses/ws-upstream.ts +8 -3
  125. package/src/server/responses-custom-tool-repair.ts +29 -9
  126. package/src/server/responses-function-tool-repair.ts +183 -0
  127. package/src/server/responses-snapshot-codec.ts +14 -0
  128. package/src/server/responses-snapshot-repair.ts +1 -13
  129. package/src/server/responses-undeclared-tool-guard.ts +1 -68
  130. package/src/server/sse-payload-rewrite.ts +3 -1
  131. package/src/service.ts +6 -2
  132. package/src/types/config.ts +23 -16
  133. package/src/types/provider.ts +14 -3
  134. package/src/types/request.ts +2 -0
  135. package/src/usage/log.ts +9 -0
  136. package/src/web-search/loop.ts +5 -2
  137. package/gui/dist/assets/index-DS1NE4Jn.css +0 -1
  138. package/gui/dist/assets/index-Djowl68T.js +0 -112
@@ -526,6 +526,45 @@ export const CAPABILITIES: readonly Capability[] = [
526
526
  "Each client has its own route because a toggle rewrites that client's own config file.",
527
527
  ],
528
528
  },
529
+ {
530
+ command: ["integration", "client"],
531
+ summary: "Inspect and toggle Aside profile catalogs, read their history, and restore a selected profile operation.",
532
+ routes: [
533
+ { method: "GET", path: "/api/client-integrations/aside/profiles" },
534
+ { method: "PUT", path: "/api/client-integrations/aside/profiles" },
535
+ { method: "GET", path: "/api/client-integrations/aside/profiles/{profileId}" },
536
+ { method: "PUT", path: "/api/client-integrations/aside/profiles/{profileId}" },
537
+ { method: "GET", path: "/api/client-integrations/aside/profiles/journal" },
538
+ { method: "GET", path: "/api/client-integrations/aside/profiles/{profileId}/journal" },
539
+ { method: "POST", path: "/api/client-integrations/aside/profiles/{profileId}/restore" },
540
+ ],
541
+ flags: [
542
+ { name: "--client", value: "string", summary: "Select the file integration; use aside for profile controls." },
543
+ { name: "--profile", value: "number", summary: "Select one registered Aside account; omitted toggles affect all profiles." },
544
+ { name: "--op", value: "string", summary: "Operation ID for restore." },
545
+ { name: "--confirm-drift", value: "boolean", summary: "Explicitly allow restore to replace subsequent edits." },
546
+ { name: "--overwrite-conflict", value: "boolean", summary: "Explicitly allow enable to replace a conflicting provider block." },
547
+ { name: "--json", value: "boolean", summary: "Emit the profile state, history, or mutation result as JSON." },
548
+ ],
549
+ mutates: true,
550
+ json: "payload",
551
+ details: [
552
+ "Use status/show/list, history/journal, enable/disable, or restore after integration client.",
553
+ "These declarations cover the dedicated Aside profile paths; existing generic client routes retain their separate parity inventory.",
554
+ ],
555
+ },
556
+ {
557
+ command: ["sync"],
558
+ summary: "Synchronize client catalogs, including Aside profiles through the running server's mutation owner.",
559
+ routes: [{ method: "POST", path: "/api/client-integrations/aside/sync" }],
560
+ flags: [
561
+ { name: "--restart-codex", value: "boolean", summary: "Restart Codex app-servers after a catalog or cache write." },
562
+ { name: "--restart-desktop-app", value: "boolean", summary: "Restart the Codex desktop app after a catalog or cache write." },
563
+ ],
564
+ mutates: true,
565
+ json: "none",
566
+ details: ["The Aside refresh uses the live server; other catalog synchronization also performs local work."],
567
+ },
529
568
  {
530
569
  command: ["agent", "request-user-input"],
531
570
  summary: "Show or set whether default mode may ask the operator a question mid-task.",
@@ -10,7 +10,7 @@ export interface ClaudeAgentStartupSyncDeps {
10
10
  }
11
11
 
12
12
  /**
13
- * Keep the public readiness gate pending until both startup reconciliations have settled.
13
+ * Keep readiness pending until the roster and optional Desktop registry have settled.
14
14
  *
15
15
  * The Codex sync remains the authority for ready versus failed. Claude roster repair is
16
16
  * deliberately best-effort (#2200), but readiness must not become observable between the
@@ -22,6 +22,7 @@ export async function reconcileClientStartupBeforeReady<T>(
22
22
  readinessGate: ReadinessGate,
23
23
  syncCodex: (deferredGate: ReadinessGate) => Promise<T>,
24
24
  syncClaudeRoster: () => Promise<unknown>,
25
+ syncDesktopRegistry?: () => Promise<unknown>,
25
26
  ): Promise<T> {
26
27
  let codexReady = false;
27
28
  const deferredGate: ReadinessGate = {
@@ -32,6 +33,7 @@ export async function reconcileClientStartupBeforeReady<T>(
32
33
 
33
34
  const result = await syncCodex(deferredGate);
34
35
  await syncClaudeRoster();
36
+ await syncDesktopRegistry?.();
35
37
  if (codexReady) readinessGate.markReady();
36
38
  return result;
37
39
  }
@@ -1,7 +1,12 @@
1
1
  import { readFileSync, writeFileSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
- import { loadConfig, saveConfigPreservingClaudeCode } from "../config";
4
- import { setIntegrationEnabled } from "../codex/desired-state";
3
+ import { loadConfig, mutatePersistedConfig, withConfigMutationLockSync } from "../config";
4
+ import { claudeDesktopIntegrationEnabledNow, setIntegrationEnabled } from "../codex/desired-state";
5
+ import { readClientConnectionState, assertClientConnectionUnchanged, assertNoClientDisconnectPending, type ClientConnectionState } from "../client/state";
6
+ import { downloadDesktop3pModels, HubClientError, normalizeHubOrigin } from "../client/hub-client";
7
+ import { readServiceApiTokenState } from "../lib/service-secrets";
8
+ import { applyRemoteDesktopStore, type DesktopStoreResult } from "../claude/desktop-remote-store";
9
+ import { withClientLifecycleSync, type ClientLifecycleLockDeps } from "../client/lifecycle-lock";
5
10
  import {
6
11
  DESKTOP_FAMILIES,
7
12
  moveDesktopRoute,
@@ -11,6 +16,7 @@ import {
11
16
  type DesktopProfile,
12
17
  } from "../claude/desktop-profile";
13
18
  import { writeDesktop3pConfig, type Desktop3pConfigMode, parseDesktop3pModeArgs } from "../claude/desktop-3p";
19
+ import { claudeDesktopPolicyWarning, probeClaudeDesktopPolicy } from "../claude/desktop-policy";
14
20
  import { filterCatalogVisibleModels, desktopVisibleNativeSlugs, nativeContextLimits } from "../codex/catalog";
15
21
  import { buildClaudeDesktopState, fetchAllModels } from "../server/management-api";
16
22
  import { findLiveProxy } from "../server/proxy-liveness";
@@ -33,6 +39,9 @@ function printDesktopHelp(): void {
33
39
  }
34
40
 
35
41
  export interface ApplyProfileDeps {
42
+ downloadDesktop3pModelsImpl?: typeof downloadDesktop3pModels;
43
+ applyRemoteDesktopStoreImpl?: typeof applyRemoteDesktopStore;
44
+ lifecycleLockDeps?: ClientLifecycleLockDeps;
36
45
  findLiveProxyImpl?: typeof findLiveProxy;
37
46
  postApplyImpl?: (
38
47
  mode: Desktop3pConfigMode,
@@ -41,20 +50,110 @@ export interface ApplyProfileDeps {
41
50
  probeClaudeDesktopPolicy?: typeof import("../claude/desktop-policy").probeClaudeDesktopPolicy;
42
51
  }
43
52
 
44
- export async function applyProfile(
53
+ /** Persist only the requested local profile, never an await-old whole configuration. */
54
+ function saveLocalDesktopProfile(
45
55
  profile: DesktopProfile,
56
+ expectedProfile: DesktopProfile | undefined,
57
+ expectedConnection: ClientConnectionState,
58
+ deps: ApplyProfileDeps,
59
+ ): void {
60
+ withClientLifecycleSync(() => {
61
+ const outcome = mutatePersistedConfig(current => {
62
+ assertNoClientDisconnectPending();
63
+ if (expectedConnection.kind === "connected") {
64
+ assertClientConnectionUnchanged(expectedConnection.value);
65
+ if (expectedConnection.value.pendingOperation) throw new Error("client_rotation_pending");
66
+ const token = readServiceApiTokenState();
67
+ if (token.kind !== "present" || token.fingerprint !== expectedConnection.value.tokenFingerprint) {
68
+ throw new Error("client_token_changed");
69
+ }
70
+ } else if (expectedConnection.kind !== "disconnected" || readClientConnectionState().kind !== "disconnected") {
71
+ throw new Error("client_connection_changed");
72
+ }
73
+ if (JSON.stringify(current.claudeCode?.desktopProfile) !== JSON.stringify(expectedProfile)) {
74
+ throw new Error("desktop_profile_changed");
75
+ }
76
+ const changed = JSON.stringify(current.claudeCode?.desktopProfile) !== JSON.stringify(profile);
77
+ if (changed) current.claudeCode = { ...(current.claudeCode ?? {}), desktopProfile: structuredClone(profile) };
78
+ return { changed, value: undefined };
79
+ });
80
+ if (outcome.status === "unavailable") throw new Error("desktop_profile_save_unavailable");
81
+ }, deps.lifecycleLockDeps);
82
+ }
83
+
84
+ async function applyConnectedDesktopProfile(
85
+ mode: Desktop3pConfigMode,
86
+ connection: Extract<ClientConnectionState, { kind: "connected" }>,
87
+ deps: ApplyProfileDeps,
88
+ ): Promise<{ ok: boolean; path: string; reason?: string; warning?: string }> {
89
+ try {
90
+ const token = withClientLifecycleSync(() => withConfigMutationLockSync(() => {
91
+ assertClientConnectionUnchanged(connection.value);
92
+ if (connection.value.pendingOperation) throw new Error("client_rotation_pending");
93
+ const current = readServiceApiTokenState();
94
+ if (current.kind === "absent") throw new Error("client_token_absent");
95
+ if (current.kind === "unsafe") throw new Error("client_token_unsafe");
96
+ if (current.fingerprint !== connection.value.tokenFingerprint) {
97
+ throw new Error("client_token_mismatch");
98
+ }
99
+ const desired = setIntegrationEnabled("claude-desktop", true);
100
+ if (!desired.ok) throw new Error("desktop_desired_state_write_failed");
101
+ return current;
102
+ }), deps.lifecycleLockDeps);
103
+ const baseUrl = normalizeHubOrigin(connection.value.serverUrl);
104
+ let snapshot: Awaited<ReturnType<typeof downloadDesktop3pModels>>;
105
+ try {
106
+ snapshot = await (deps.downloadDesktop3pModelsImpl ?? downloadDesktop3pModels)(baseUrl, token.token);
107
+ } catch (error) {
108
+ return { ok: false, path: "", reason: error instanceof HubClientError ? error.code : "desktop_download_failed" };
109
+ }
110
+ const result: DesktopStoreResult = withClientLifecycleSync(held => withConfigMutationLockSync(() => {
111
+ assertClientConnectionUnchanged(connection.value);
112
+ const currentToken = readServiceApiTokenState();
113
+ if (currentToken.kind !== "present" || currentToken.fingerprint !== connection.value.tokenFingerprint) {
114
+ throw new Error("client_connection_changed");
115
+ }
116
+ if (!claudeDesktopIntegrationEnabledNow()) throw new Error("desired_state_changed");
117
+ if (snapshot.models.length === 0) throw new Error("desktop_unavailable");
118
+ return (deps.applyRemoteDesktopStoreImpl ?? applyRemoteDesktopStore)(held, {
119
+ owner: { serverUrl: connection.value.serverUrl, apiKeyId: connection.value.apiKeyId, connectedAt: connection.value.connectedAt },
120
+ expectedTokenFingerprint: currentToken.fingerprint,
121
+ baseUrl, apiKey: currentToken.token, mode, models: snapshot.models,
122
+ });
123
+ }), deps.lifecycleLockDeps);
124
+ if (!result.ok) return { ok: false, path: "", reason: `desktop_lifecycle_${result.reason}` };
125
+ // Policy probes may spawn a process; perform them only after L/C have been released.
126
+ const policyWarning = claudeDesktopPolicyWarning((deps.probeClaudeDesktopPolicy ?? probeClaudeDesktopPolicy)());
127
+ const fallbackWarning = result.baselineKind === "standard_fallback"
128
+ ? "Previous Desktop settings were not recorded. Disconnect will switch this managed profile to standard mode."
129
+ : undefined;
130
+ const warning = [fallbackWarning, policyWarning].filter(Boolean).join(" ");
131
+ return { ok: true, path: result.path ?? "", ...(warning ? { warning } : {}) };
132
+ } catch (error) {
133
+ const message = error instanceof Error ? error.message : "";
134
+ return { ok: false, path: "", reason: /^[a-z][a-z0-9_]{1,100}$/.test(message) ? message : "desktop_apply_failed" };
135
+ }
136
+ }
137
+
138
+ export async function applyProfile(
139
+ profile: DesktopProfile | undefined,
46
140
  mode: Desktop3pConfigMode,
47
141
  deps: ApplyProfileDeps = {},
48
142
  ): Promise<{ ok: boolean; path: string; reason?: string; warning?: string }> {
143
+ try { assertNoClientDisconnectPending(); } catch { return { ok: false, path: "", reason: "client_disconnect_pending" }; }
144
+ const connection = readClientConnectionState();
145
+ if (connection.kind === "connected") return applyConnectedDesktopProfile(mode, connection, deps);
146
+ if (connection.kind !== "disconnected") return { ok: false, path: "", reason: "client_connection_invalid" };
49
147
  // Explicit apply is an enable action. Persist intent before any Desktop write
50
148
  // so a process crash cannot leave a gateway profile that startup immediately removes.
51
149
  const desired = setIntegrationEnabled("claude-desktop", true);
52
150
  if (!desired.ok) return { ok: false, path: "", reason: desired.message };
53
151
  const config = loadConfig();
54
152
  const state = await buildClaudeDesktopState(config, profile);
55
- config.claudeCode = { ...(config.claudeCode ?? {}), desktopProfile: state.profile };
56
- saveConfigPreservingClaudeCode(config);
153
+ saveLocalDesktopProfile(state.profile, config.claudeCode?.desktopProfile, connection, deps);
57
154
  const live = await (deps.findLiveProxyImpl ?? findLiveProxy)();
155
+ assertNoClientDisconnectPending();
156
+ if (readClientConnectionState().kind !== "disconnected") throw new Error("client_connection_changed");
58
157
  if (live) {
59
158
  // #859: the Desktop alias reverse-map is process-local. Applying through the
60
159
  // serving process installs the map there; a local-only write leaves the
@@ -86,7 +185,8 @@ export async function applyProfile(
86
185
  // The toggle can persist OFF while fetchAllModels was awaiting (same race the
87
186
  // management writers fence). Re-read persisted intent immediately before the
88
187
  // writer; a lost race is a discriminated skip, not a write.
89
- const { claudeDesktopIntegrationEnabledNow } = await import("../codex/desired-state");
188
+ assertNoClientDisconnectPending();
189
+ if (readClientConnectionState().kind !== "disconnected") throw new Error("client_connection_changed");
90
190
  if (!claudeDesktopIntegrationEnabledNow()) {
91
191
  return { ok: false, path: "", reason: "desired_state_changed" };
92
192
  }
@@ -103,8 +203,8 @@ export async function applyProfile(
103
203
  mode,
104
204
  state.profile,
105
205
  nativeContextLimits(config),
206
+ deps.lifecycleLockDeps,
106
207
  );
107
- const { claudeDesktopPolicyWarning, probeClaudeDesktopPolicy } = await import("../claude/desktop-policy");
108
208
  const policyState = (deps.probeClaudeDesktopPolicy ?? probeClaudeDesktopPolicy)();
109
209
  const warning = result.written ? claudeDesktopPolicyWarning(policyState) : undefined;
110
210
  return {
@@ -134,12 +234,9 @@ export async function handleClaudeDesktopCommand(argv: string[], deps: ApplyProf
134
234
  const parsedMode = parseDesktop3pModeArgs(legacyFlags);
135
235
  if ("error" in parsedMode) { console.error(parsedMode.error); return 2; }
136
236
  try {
137
- const config = loadConfig();
138
- const state = await buildClaudeDesktopState(config);
139
- const result = await applyProfile(state.profile, parsedMode.mode, deps);
237
+ const result = await applyProfile(undefined, parsedMode.mode, deps);
140
238
  if (!result.ok) {
141
239
  console.error(`설정 적용 실패: ${result.reason ?? "unknown error"}`);
142
- console.error("프로필은 저장되었지만 Claude Desktop 설정 파일에는 반영되지 않았습니다. 프록시 상태를 확인한 뒤 다시 적용해 주세요.");
143
240
  return 1;
144
241
  }
145
242
  console.log(`Claude Desktop 설정을 적용했습니다: ${result.path}`);
@@ -155,6 +252,17 @@ export async function handleClaudeDesktopCommand(argv: string[], deps: ApplyProf
155
252
  }
156
253
 
157
254
  try {
255
+ const connection = readClientConnectionState();
256
+ if (command === "import" && argv.includes("--apply")) assertNoClientDisconnectPending();
257
+ if (command === "import" && argv.includes("--apply") && connection.kind !== "disconnected") {
258
+ throw new CliUsageError(connection.kind === "connected"
259
+ ? "Connected Desktop apply uses the hub profile. Import on the hub, then run ocx claude desktop apply here."
260
+ : "Client connection state is invalid; refusing import --apply.");
261
+ }
262
+ const localView = connection.kind === "connected";
263
+ if (localView && ["show", "export", "move", "default", "import"].includes(command ?? "")) {
264
+ console.warn("Local client profile only; connected Desktop apply uses the hub profile.");
265
+ }
158
266
  const config = loadConfig();
159
267
  // `status` is API-backed and must NOT build local state first: the whole point of the
160
268
  // route the GUI polls (/api/claude-desktop/status) is the applied-vs-desired comparison,
@@ -178,7 +286,7 @@ export async function handleClaudeDesktopCommand(argv: string[], deps: ApplyProf
178
286
  const rest = argv.slice(1);
179
287
  const wantsJson = takeJsonFlag(rest);
180
288
  if (rest.length > 0) throw new CliUsageError("Usage: ocx claude desktop show [--json]");
181
- if (wantsJson) console.log(JSON.stringify(state));
289
+ if (wantsJson) console.log(JSON.stringify(localView ? { ...state, scope: "local" } : state));
182
290
  else {
183
291
  for (const family of DESKTOP_FAMILIES) {
184
292
  console.log(`${family.toUpperCase()}${state.profile.defaults[family] ? ` (default: ${state.profile.defaults[family]})` : ""}`);
@@ -194,8 +302,7 @@ export async function handleClaudeDesktopCommand(argv: string[], deps: ApplyProf
194
302
  if (!route || !isFamily(familyRaw) || flags.some(flag => flag !== "--default")) throw new CliUsageError("Usage: ocx claude desktop move <route> <family> [--default]");
195
303
  if (!state.models.some(model => model.route === route && model.available)) throw new Error(`현재 사용할 수 없는 모델입니다: ${route}`);
196
304
  const profile = moveDesktopRoute(state.profile, route, familyRaw, flags.includes("--default"));
197
- config.claudeCode = { ...(config.claudeCode ?? {}), desktopProfile: profile };
198
- saveConfigPreservingClaudeCode(config);
305
+ saveLocalDesktopProfile(profile, config.claudeCode?.desktopProfile, connection, deps);
199
306
  console.log(`${route} 모델을 ${familyRaw} 그룹으로 옮겼습니다.`);
200
307
  return 0;
201
308
  }
@@ -205,8 +312,7 @@ export async function handleClaudeDesktopCommand(argv: string[], deps: ApplyProf
205
312
  const route = routeRaw === "none" ? null : routeRaw;
206
313
  if (route && !state.models.some(model => model.route === route && model.available)) throw new Error(`현재 사용할 수 없는 모델입니다: ${route}`);
207
314
  const profile = setDesktopFamilyDefault(state.profile, familyRaw, route);
208
- config.claudeCode = { ...(config.claudeCode ?? {}), desktopProfile: profile };
209
- saveConfigPreservingClaudeCode(config);
315
+ saveLocalDesktopProfile(profile, config.claudeCode?.desktopProfile, connection, deps);
210
316
  console.log(`${familyRaw} 기본 모델을 ${route ?? "없음"}으로 지정했습니다.`);
211
317
  return 0;
212
318
  }
@@ -224,8 +330,11 @@ export async function handleClaudeDesktopCommand(argv: string[], deps: ApplyProf
224
330
  if (!source || flags.some(flag => flag !== "--apply")) throw new CliUsageError("Usage: ocx claude desktop import <path> [--apply]");
225
331
  const profile = parseDesktopProfile(JSON.parse(readFileSync(resolve(source), "utf8")));
226
332
  const reconciled = (await buildClaudeDesktopState(config, profile)).profile;
227
- config.claudeCode = { ...(config.claudeCode ?? {}), desktopProfile: reconciled };
228
- saveConfigPreservingClaudeCode(config);
333
+ if (flags.includes("--apply")) assertNoClientDisconnectPending();
334
+ if (flags.includes("--apply") && readClientConnectionState().kind !== "disconnected") {
335
+ throw new CliUsageError("Client connection changed; refusing import --apply. Connected Desktop apply uses the hub profile.");
336
+ }
337
+ saveLocalDesktopProfile(reconciled, config.claudeCode?.desktopProfile, connection, deps);
229
338
  if (flags.includes("--apply")) {
230
339
  const result = await applyProfile(reconciled, "static", deps);
231
340
  if (!result.ok) { console.error(`프로필은 저장했지만 Desktop 적용에 실패했습니다: ${result.reason ?? "unknown error"}`); return 1; }
@@ -8,6 +8,8 @@ import {
8
8
  } from "../client/connect";
9
9
  import { inspectClientRotationRecoveryGate, readClientConnectionState } from "../client/state";
10
10
  import { readServiceApiTokenState } from "../lib/service-secrets";
11
+ import type { ClientLifecycleLockDeps } from "../client/lifecycle-lock";
12
+ import { inspectRemoteDesktopStore } from "../claude/desktop-remote-store";
11
13
  import type { OcxConnectedClientId } from "../types";
12
14
  import {
13
15
  CliUsageError,
@@ -22,6 +24,10 @@ import {
22
24
  type RuntimeApiDeps,
23
25
  } from "./runtime-api";
24
26
 
27
+ export interface ClientCommandDeps extends RuntimeApiDeps {
28
+ lifecycleLockDeps?: ClientLifecycleLockDeps;
29
+ }
30
+
25
31
  export const CONNECT_USAGE = `Usage:
26
32
  ocx connect <url> [--management-url <url>]
27
33
  (--pairing-code-stdin | --admin-token-stdin)
@@ -52,10 +58,10 @@ export type ClientConnectionStatus = {
52
58
  rotation: "clean" | "orphan-cleaned" | "recovery-required" | "unsafe";
53
59
  };
54
60
 
55
- export function collectClientConnectionStatus(now = Date.now()): ClientConnectionStatus {
61
+ export function collectClientConnectionStatus(now = Date.now(), lifecycleLockDeps?: ClientLifecycleLockDeps): ClientConnectionStatus {
56
62
  const state = readClientConnectionState();
57
63
  const tokenState = readServiceApiTokenState();
58
- const rotation = inspectClientRotationRecoveryGate(state).kind;
64
+ const rotation = inspectClientRotationRecoveryGate(state, lifecycleLockDeps).kind;
59
65
  let catalog: ClientConnectionStatus["catalog"] = "missing";
60
66
  if (existsSync(DEFAULT_CATALOG_PATH)) {
61
67
  try {
@@ -124,7 +130,7 @@ function statusLines(status: ClientConnectionStatus): string[] {
124
130
  ];
125
131
  }
126
132
 
127
- async function runRotate(argv: string[], deps: RuntimeApiDeps): Promise<void> {
133
+ async function runRotate(argv: string[], deps: ClientCommandDeps): Promise<void> {
128
134
  const args = [...argv];
129
135
  const wantsJson = takeFlag(args, "--json");
130
136
  const pairing = takeFlag(args, "--pairing-code-stdin");
@@ -136,13 +142,17 @@ async function runRotate(argv: string[], deps: RuntimeApiDeps): Promise<void> {
136
142
  const value = new TextEncoder().encode(await readSecretLine(deps, pairing ? "pairing code" : "admin token"));
137
143
  const connection = await rotateConnectedClientKey({
138
144
  credential: { kind: pairing ? "pairing-grant" : "admin", value },
139
- }, { fetchImpl: deps.fetchImpl });
140
- printData({ apiKeyId: connection.apiKeyId, rotation: "committed" }, wantsJson, [
141
- `Rotated connected API key ${connection.apiKeyId}; the previous key is no longer admitted.`,
145
+ }, { fetchImpl: deps.fetchImpl, lifecycleLockDeps: deps.lifecycleLockDeps });
146
+ const restartRequired = inspectRemoteDesktopStore({ serverUrl: connection.serverUrl, apiKeyId: connection.apiKeyId, connectedAt: connection.connectedAt }).kind !== "absent";
147
+ printData({ apiKeyId: connection.apiKeyId, rotation: connection.rotationOutcome, restartRequired }, wantsJson, [
148
+ connection.rotationOutcome === "committed"
149
+ ? `Rotated connected API key ${connection.apiKeyId}; the previous key is no longer admitted.`
150
+ : `Rotation rolled back for API key ${connection.apiKeyId}; the previous key was retained or restored.`,
151
+ ...(restartRequired ? ["Fully quit and reopen Claude Desktop; a running app may still hold the previous credential."] : []),
142
152
  ]);
143
153
  }
144
154
 
145
- async function runConnect(argv: string[], deps: RuntimeApiDeps): Promise<void> {
155
+ async function runConnect(argv: string[], deps: ClientCommandDeps): Promise<void> {
146
156
  const args = [...argv];
147
157
  const serverUrl = args.shift();
148
158
  if (!serverUrl || serverUrl.startsWith("--")) throw new CliUsageError("hub URL is required", CONNECT_USAGE);
@@ -173,28 +183,28 @@ async function runConnect(argv: string[], deps: RuntimeApiDeps): Promise<void> {
173
183
  managementTransport,
174
184
  noSync,
175
185
  ...(catalogTimeoutSeconds === undefined ? {} : { catalogTimeoutMs: catalogTimeoutSeconds * 1_000 }),
176
- }, { fetchImpl: deps.fetchImpl });
186
+ }, { fetchImpl: deps.fetchImpl, lifecycleLockDeps: deps.lifecycleLockDeps });
177
187
  console.log(`Connected to ${connection.serverUrl} as key ${connection.apiKeyId}.`);
178
188
  }
179
189
 
180
- async function runRevoke(argv: string[], deps: RuntimeApiDeps): Promise<void> {
190
+ async function runRevoke(argv: string[], deps: ClientCommandDeps): Promise<void> {
181
191
  const args = [...argv];
182
192
  const wantsJson = takeFlag(args, "--json");
183
193
  const admin = takeFlag(args, "--admin-token-stdin");
184
194
  if (!admin) throw new CliUsageError("revoke requires --admin-token-stdin", CONNECT_USAGE);
185
195
  rejectArgs(args, CONNECT_USAGE, { redactValues: true });
186
196
  const value = new TextEncoder().encode(await readSecretLine(deps, "admin token"));
187
- const result = await revokeConnectedClientKey({ kind: "admin", value }, { fetchImpl: deps.fetchImpl });
197
+ const result = await revokeConnectedClientKey({ kind: "admin", value }, { fetchImpl: deps.fetchImpl, lifecycleLockDeps: deps.lifecycleLockDeps });
188
198
  printData(result, wantsJson, [`Revoked connected API key ${result.apiKeyId}. Disconnect this client next.`]);
189
199
  }
190
200
 
191
- export async function handleConnectCommand(argv: string[], deps: RuntimeApiDeps = {}): Promise<number> {
201
+ export async function handleConnectCommand(argv: string[], deps: ClientCommandDeps = {}): Promise<number> {
192
202
  return runCliAction(async () => {
193
203
  if (argv[0] === "status") {
194
204
  const args = argv.slice(1);
195
205
  const wantsJson = takeFlag(args, "--json");
196
206
  rejectArgs(args, CONNECT_USAGE, { redactValues: true });
197
- const status = collectClientConnectionStatus();
207
+ const status = collectClientConnectionStatus(Date.now(), deps.lifecycleLockDeps);
198
208
  printData(status, wantsJson, statusLines(status));
199
209
  return;
200
210
  }
@@ -210,13 +220,13 @@ export async function handleConnectCommand(argv: string[], deps: RuntimeApiDeps
210
220
  });
211
221
  }
212
222
 
213
- export async function handleDisconnectCommand(argv: string[]): Promise<number> {
223
+ export async function handleDisconnectCommand(argv: string[], deps: Pick<ClientCommandDeps, "lifecycleLockDeps"> = {}): Promise<number> {
214
224
  return runCliAction(async () => {
215
225
  const args = [...argv];
216
226
  const keepCatalog = takeFlag(args, "--keep-catalog");
217
227
  const wantsJson = takeFlag(args, "--json");
218
228
  rejectArgs(args, DISCONNECT_USAGE, { redactValues: true });
219
- const result = await disconnectClient({ keepCatalog });
229
+ const result = await disconnectClient({ keepCatalog }, deps);
220
230
  const payload = {
221
231
  ...result,
222
232
  revoke: {
@@ -226,6 +236,9 @@ export async function handleDisconnectCommand(argv: string[]): Promise<number> {
226
236
  };
227
237
  printData(payload, wantsJson, [
228
238
  "Disconnected locally; native Codex state was restored.",
239
+ ...(result.desktopRestoration === "standard_fallback"
240
+ ? ["Previous Desktop settings were not recorded; the managed profile was switched to standard mode."] : []),
241
+ ...(result.restartRequired ? ["Fully quit and reopen Claude Desktop; local cleanup cannot revoke an in-memory credential."] : []),
229
242
  `The hub key ${result.apiKeyId} is still valid. Revoke it from Integrations → API Keys.`,
230
243
  ]);
231
244
  });
@@ -14,6 +14,7 @@ import type { CliHead } from "./root";
14
14
  import type { ReadyArgs } from "./ready";
15
15
  import type { LivenessIo, LiveProxy } from "../server/proxy-liveness";
16
16
  import type { OcxConfig } from "../types";
17
+ import type { OwnedIntegrationRefreshOutcome } from "../integrations/owned-refresh";
17
18
  import { hasHelpFlag, printSubcommandUsage, printUsage } from "./help";
18
19
  import { setIntegrationEnabled, shouldSyncCodexOnStart } from "../codex/desired-state";
19
20
  import { syncModelsToCodex } from "../codex/sync";
@@ -387,25 +388,38 @@ const commandRunners: Record<string, CommandRunner> = {
387
388
  if (restartDesktopApp) await handleDesktopAppRestart(console);
388
389
  }
389
390
  // `ocx sync` is a direct CLI path; it does not call the management
390
- // `/api/sync` route. Refresh the already-connected MCode block here too,
391
+ // `/api/sync` route. Refresh already-connected file integrations here too,
391
392
  // after Codex has published the catalog that supplies its capabilities.
392
- if (synced.status !== "refused" && live) {
393
+ if (synced.status !== "refused") {
394
+ const results: OwnedIntegrationRefreshOutcome[] = [];
395
+ if (live) {
396
+ try {
397
+ const config = deps.loadConfig();
398
+ const { refreshOwnedCatalogIntegrations } = await import("../integrations/catalog-refresh");
399
+ results.push(...await refreshOwnedCatalogIntegrations({
400
+ models: async () => {
401
+ const { loadExportModels } = await import("../server/management/model-rows");
402
+ return loadExportModels(config);
403
+ },
404
+ config,
405
+ port: live.port,
406
+ }, ["mcode", "pi"]));
407
+ } catch (error) {
408
+ console.warn(`Client integrations were not refreshed: ${error instanceof Error ? error.message : String(error)}`);
409
+ }
410
+ }
411
+ // Even without a live proxy, report why Aside could not sync. Its server
412
+ // owner is never bypassed, and another client's failure cannot hide it.
393
413
  try {
394
- const config = deps.loadConfig();
395
- const { refreshOwnedIntegration } = await import("../integrations/owned-refresh");
396
- const result = await refreshOwnedIntegration({
397
- clientId: "mcode",
398
- models: async () => {
399
- const { loadExportModels } = await import("../server/management/model-rows");
400
- return loadExportModels(config);
401
- },
402
- config,
403
- port: live.port,
404
- });
405
- if (result?.changed) console.log("MCode integration refreshed from the current catalog.");
406
- else if (result?.reason) console.warn(`MCode integration was not refreshed: ${result.reason}`);
414
+ const { refreshAsideProfilesThroughServer } = await import("./aside-profiles");
415
+ results.push(...await refreshAsideProfilesThroughServer({ findLiveProxy: async () => live }));
407
416
  } catch (error) {
408
- console.warn(`MCode integration was not refreshed: ${error instanceof Error ? error.message : String(error)}`);
417
+ console.warn(`Aside profiles were not refreshed: ${error instanceof Error ? error.message : String(error)}`);
418
+ }
419
+ for (const result of results) {
420
+ const label = result.profileId === undefined ? result.client : `${result.client}:${result.profileId}`;
421
+ if (result.changed) console.log(`${label} integration refreshed from the current catalog.`);
422
+ else if (result.reason) console.warn(`${label} integration was not refreshed: ${result.reason}${result.residual ? " Recovery did not finish." : ""}${result.snapshotPath ? ` Backup: ${result.snapshotPath}` : ""}`);
409
423
  }
410
424
  }
411
425
  return code;
@@ -171,12 +171,14 @@ export async function handleExportCommand(argv: string[], deps: ExportCommandDep
171
171
  rejectArgs(args, USAGE);
172
172
 
173
173
  const spec = EXPORT_CLIENTS[client];
174
- const config = (deps.configImpl ?? loadConfig)();
175
174
  const root = await runtimeBaseUrl(deps);
176
175
  const rows = await runtimeRequest<ExportProxyModelRow[]>("/api/models", {}, { ...deps, baseUrl: root });
177
176
  if (!Array.isArray(rows)) {
178
177
  throw new RuntimeApiError("Management API returned an unexpected /api/models payload.", 502, rows);
179
178
  }
179
+ // Discovery can persist pending -> ready selection. Read from the caller's
180
+ // config source after the response, rather than filtering with a stale snapshot.
181
+ const config = (deps.configImpl ?? loadConfig)();
180
182
  const models = exportModelsFromProxyRows(rows, config);
181
183
  // The text is the client's OWN format — YAML, TOML and JSON5 clients would
182
184
  // otherwise receive a JSON rendering their parser reads differently.
package/src/cli/index.ts CHANGED
@@ -106,7 +106,7 @@ function reportShellHookFailure(result: { state: "installed" | "absent" | "faile
106
106
  }
107
107
 
108
108
 
109
- import { removeOwnedConfigState } from "../lib/config-ownership";
109
+ import { removeOwnedConfigAfterDesktopCleanup } from "./uninstall-client-state";
110
110
  import { withProcessRuntimeProvenance } from "../lib/bun-runtime";
111
111
  import { selfLaunchArgv } from "../lib/self-launch-argv";
112
112
  import { initializeNodeLauncherContext } from "./launcher-context";
@@ -459,8 +459,8 @@ async function handleStart(options: { block?: boolean } = {}) {
459
459
  reportShellHookFailure(reconcileShellHook(systemEnv.injected));
460
460
  await maybeShowStarPrompt(); // once-only Yes/No GitHub-star prompt on first interactive start
461
461
  // Codex sync owns the ready/failed verdict, but its successful transition is
462
- // deferred until the best-effort Claude roster reconciliation settles. This
463
- // keeps /readyz closed across both startup writes without making an optional
462
+ // deferred until the best-effort Claude roster and Desktop registry settle. This
463
+ // keeps /readyz closed across startup initialization without making an optional
464
464
  // Claude integration failure prevent the proxy from starting.
465
465
  const startupSync = await reconcileClientStartupBeforeReady(
466
466
  readinessGate,
@@ -468,6 +468,29 @@ async function handleStart(options: { block?: boolean } = {}) {
468
468
  () => systemEnv.injected
469
469
  ? Promise.resolve(null)
470
470
  : syncClaudeAgentDefsAtProxyStartup(config, port),
471
+ async () => {
472
+ try {
473
+ const { fetchAllModels } = await import("../server/management-api");
474
+ const { desktopVisibleNativeSlugs } = await import("../codex/catalog");
475
+ const { resolveCodexModelEntitlements } = await import("../codex/model-entitlements");
476
+ const { buildDesktopDiscoveryInputs } = await import("../claude/desktop-discovery-inputs");
477
+ const [models, modelEntitlements] = await Promise.all([
478
+ fetchAllModels(config),
479
+ resolveCodexModelEntitlements(config, { clientVersion: null }),
480
+ ]);
481
+ const inputs = buildDesktopDiscoveryInputs({
482
+ config, models, modelEntitlements,
483
+ desktopNativeCandidates: desktopVisibleNativeSlugs(config),
484
+ });
485
+ buildDesktop3pRegistry(
486
+ inputs.nativeSlugs, inputs.routedModels,
487
+ config.claudeCode?.desktopProfile, inputs.nativeContextCap,
488
+ );
489
+ } catch {
490
+ // Best-effort; model discovery can rebuild it. Never reflect credential or provider errors.
491
+ console.warn("[opencodex] Claude Desktop model registry could not be initialized at startup.");
492
+ }
493
+ },
471
494
  );
472
495
  if (!startupSync.ran) console.log(" Codex integration OFF; startup left Codex native.");
473
496
  // #1046: one warning per startup, after BOTH writes. The server's cache
@@ -482,17 +505,6 @@ async function handleStart(options: { block?: boolean } = {}) {
482
505
  if (!currentExternalCodexModelProvider() && !shouldInjectApiAuthHeader(config) && config.syncResumeHistory !== false) {
483
506
  historyGuardian = startHistoryMigrationGuardian();
484
507
  }
485
- // Build Desktop 3P alias registry so inbound claude-opus-4-8-{code} aliases (and legacy claude-opus-4-{code}) decode correctly.
486
- try {
487
- const { fetchAllModels } = await import("../server/management-api");
488
- const { visibleNativeSlugs, filterCatalogVisibleModels } = await import("../codex/catalog");
489
- const models = filterCatalogVisibleModels(await fetchAllModels(config), config);
490
- buildDesktop3pRegistry(
491
- [...visibleNativeSlugs(config)],
492
- models.map(m => ({ provider: m.provider, id: m.id, contextWindow: m.contextWindow })),
493
- config.claudeCode?.desktopProfile,
494
- );
495
- } catch { /* best-effort — registry rebuilds on first /v1/models call */ }
496
508
  // Grok Build auto-registration: additive fenced block in ~/.grok/config.toml so an installed
497
509
  // grok CLI can pick opencodex-routed models without manual config. No-op when ~/.grok is
498
510
  // absent or the bind is non-loopback; removed again by stop/eject/uninstall/shutdown.
@@ -1268,8 +1280,8 @@ async function handleUninstall() {
1268
1280
  }
1269
1281
 
1270
1282
  if (failures.length === 0) {
1271
- await runStep("opencodex config removed", () => {
1272
- const result = removeOwnedConfigState(getConfigDir());
1283
+ await runStep("opencodex config removed", async () => {
1284
+ const result = await removeOwnedConfigAfterDesktopCleanup(observed);
1273
1285
  if (result.status === "absent") return false;
1274
1286
  if (result.status === "removed") return true;
1275
1287
  const residual = result.residualPaths.length > 0