@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
@@ -0,0 +1,231 @@
1
+ import { redactSecretString } from "../../lib/redact";
2
+ import { ClientPathError } from "../../clients/config-export";
3
+ import { IntegrationMutationBusyError } from "../../integrations/mutation-flight";
4
+ import { IntegrationWriterLockBusyError } from "../../integrations/writer-lock";
5
+ import {
6
+ getAsideProfileState, listAsideProfileStates, mutateAsideProfiles, refreshAsideProfiles,
7
+ type AsideProfilesInput,
8
+ } from "../../integrations/aside-profiles";
9
+ import {
10
+ listAsideOperations, findAsideOperation, restoreAsideProfile, deleteAsideOperation,
11
+ asideOperationMatchesCurrent,
12
+ } from "../../integrations/aside-profile-journal";
13
+ import type { WriteRefused } from "../../integrations/writer";
14
+ import type { ManagementContext } from "./context";
15
+ import { readManagementJsonBody, readOptionalManagementJsonBody, rethrowManagementBodyTooLarge } from "./body";
16
+ import { jsonResponse } from "../auth-cors";
17
+
18
+ export interface AsideProfileRouteOptions {
19
+ input: () => AsideProfilesInput;
20
+ failure: (result: WriteRefused) => Response;
21
+ }
22
+
23
+ class ProfileQueryError extends Error { readonly status = 400; readonly code = "invalid_aside_profile"; }
24
+
25
+ const ASIDE_INTEGRATION_PATH = "/api/client-integrations/aside";
26
+ const ASIDE_PROFILES_PATH = "/api/client-integrations/aside/profiles";
27
+
28
+ function profileId(ctx: ManagementContext): number | undefined {
29
+ const raw = ctx.url.searchParams.get("profile");
30
+ if (raw === null) return undefined;
31
+ if (!/^(0|[1-9][0-9]*)$/.test(raw) || !Number.isSafeInteger(Number(raw))) {
32
+ throw new ProfileQueryError("profile must be a nonnegative integer account ID");
33
+ }
34
+ return Number(raw);
35
+ }
36
+
37
+ function errorResponse(error: unknown, ctx: ManagementContext): Response {
38
+ rethrowManagementBodyTooLarge(error);
39
+ const detail = error as { status?: unknown; code?: unknown } | null;
40
+ const busy = error instanceof IntegrationMutationBusyError || error instanceof IntegrationWriterLockBusyError;
41
+ const status = busy ? 409 : typeof detail?.status === "number" && [400,404,409,410,500].includes(detail.status)
42
+ ? detail.status : error instanceof ClientPathError ? 409 : 500;
43
+ const code = busy ? "integration_mutation_busy"
44
+ : typeof detail?.code === "string" ? detail.code : "aside_profile_error";
45
+ return jsonResponse({
46
+ error: redactSecretString(error instanceof Error ? error.message : "Aside profile operation failed"),
47
+ code, clientId: "aside",
48
+ }, status, ctx.req, ctx.config);
49
+ }
50
+
51
+ function isObject(value: unknown): value is Record<string, unknown> {
52
+ return value !== null && typeof value === "object" && !Array.isArray(value);
53
+ }
54
+
55
+ async function readProfileBody(req: Request, optional = false): Promise<unknown> {
56
+ try { return await (optional ? readOptionalManagementJsonBody(req) : readManagementJsonBody(req)); }
57
+ catch (error) { rethrowManagementBodyTooLarge(error); throw new ProfileQueryError("invalid JSON body"); }
58
+ }
59
+
60
+ function validateClientSelector(ctx: ManagementContext): void {
61
+ const client = ctx.url.searchParams.get("client");
62
+ if (client !== null && client !== "aside") throw new ProfileQueryError("client/profile selectors must identify Aside");
63
+ }
64
+
65
+ /** Dedicated scoped paths fail closed even when a newer client reaches an older server. */
66
+ function nestedProfileContext(ctx: ManagementContext): { ctx: ManagementContext; action?: string } {
67
+ const prefix = "/api/client-integrations/aside/profiles/";
68
+ if (!ctx.url.pathname.startsWith(prefix)) return { ctx };
69
+ validateClientSelector(ctx);
70
+ const parts = ctx.url.pathname.slice(prefix.length).split("/");
71
+ const url = new URL(ctx.url);
72
+ if (parts.length === 1 && parts[0] === "journal") {
73
+ if (url.searchParams.has("profile")) throw new ProfileQueryError("Use a profile-specific journal path");
74
+ url.pathname = "/api/client-integrations/journal";
75
+ url.searchParams.set("client", "aside");
76
+ return { ctx: { ...ctx, url }, action: "journal" };
77
+ }
78
+ if (parts.length > 2 || !parts[0] || (parts[1] !== undefined && !["journal", "restore"].includes(parts[1]))) {
79
+ throw new ProfileQueryError("Invalid Aside profile path");
80
+ }
81
+ const prior = url.searchParams.get("profile");
82
+ if (prior !== null && prior !== parts[0]) throw new ProfileQueryError("Conflicting Aside profile selectors");
83
+ url.searchParams.set("profile", parts[0]);
84
+ url.searchParams.set("client", "aside");
85
+ url.pathname = parts[1] ? `/api/client-integrations/${parts[1]}` : "/api/client-integrations/aside";
86
+ return { ctx: { ...ctx, url }, action: parts[1] };
87
+ }
88
+
89
+ /** Own only Aside status/toggle paths; other clients keep the existing adapter. */
90
+ export async function handleAsideProfileRoutes(
91
+ ctx: ManagementContext, options: AsideProfileRouteOptions,
92
+ ): Promise<Response | null> {
93
+ if (ctx.url.pathname !== ASIDE_INTEGRATION_PATH
94
+ && !ctx.url.pathname.startsWith(`${ASIDE_INTEGRATION_PATH}/`)) return null;
95
+ try {
96
+ const normalized = nestedProfileContext(ctx);
97
+ ctx = normalized.ctx;
98
+ const { req, url } = ctx;
99
+ validateClientSelector(ctx);
100
+ const id = profileId(ctx);
101
+ if (normalized.action === "journal") {
102
+ if (req.method === "GET") return asideJournalResponse(ctx, "aside", options);
103
+ if (req.method === "DELETE") {
104
+ const opId = url.searchParams.get("opId")?.trim();
105
+ if (!opId) throw new ProfileQueryError("opId is required");
106
+ return asideJournalDeleteResponse(ctx, opId, options);
107
+ }
108
+ return null;
109
+ }
110
+ if (normalized.action === "restore") {
111
+ if (req.method !== "POST") return null;
112
+ const body = await readProfileBody(req);
113
+ if (!isObject(body) || typeof body.opId !== "string" || !body.opId.trim()
114
+ || (body.confirmDrift !== undefined && typeof body.confirmDrift !== "boolean")) throw new ProfileQueryError("Invalid Aside restore request");
115
+ return asideRestoreResponse(ctx, { opId: body.opId.trim(), confirmDrift: body.confirmDrift === true }, options);
116
+ }
117
+ if (url.pathname === "/api/client-integrations/aside/sync") {
118
+ if (req.method !== "POST") return null;
119
+ if (id !== undefined) throw new ProfileQueryError("Aside sync uses the server's selected profiles");
120
+ const body = await readProfileBody(req, true);
121
+ if (!isObject(body) || Object.keys(body).length !== 0) throw new ProfileQueryError("Aside sync expects an empty object");
122
+ const results = await refreshAsideProfiles(options.input());
123
+ const ok = results.every(result => result.ok);
124
+ return jsonResponse({ ok, clientId: "aside", results }, ok ? 200 : 207, req, ctx.config);
125
+ }
126
+ if (url.pathname !== ASIDE_INTEGRATION_PATH && url.pathname !== ASIDE_PROFILES_PATH) return null;
127
+ if (req.method !== "GET" && req.method !== "PUT") return null;
128
+ if (url.pathname.endsWith("/profiles") && id !== undefined) throw new ProfileQueryError("Use a profile-specific path");
129
+ if (req.method === "GET") {
130
+ const state = id === undefined ? await listAsideProfileStates(options.input()) : await getAsideProfileState(options.input(), id);
131
+ return jsonResponse(state, 200, req, ctx.config);
132
+ }
133
+ const body = await readProfileBody(req);
134
+ if (!isObject(body) || typeof body.enabled !== "boolean") throw new ProfileQueryError("enabled must be a boolean");
135
+ if (body.overwriteConflict !== undefined && typeof body.overwriteConflict !== "boolean") throw new ProfileQueryError("overwriteConflict must be a boolean");
136
+ if (body.overwriteConflict === true && !body.enabled) throw new ProfileQueryError("overwriteConflict applies only to enabling an integration");
137
+ const batch = await mutateAsideProfiles(options.input(), { enabled: body.enabled, profileId: id, overwriteConflict: body.overwriteConflict === true });
138
+ if (id !== undefined) {
139
+ const result = batch.results[0];
140
+ if (!result) throw new Error("Aside profile mutation returned no result");
141
+ return result.ok ? jsonResponse(result, 200, req, ctx.config) : options.failure(result);
142
+ }
143
+ return jsonResponse(batch, batch.ok ? 200 : 207, req, ctx.config);
144
+ } catch (error) { return errorResponse(error, ctx); }
145
+ }
146
+
147
+ /** Profile-qualified history, including source-store provenance for imported legacy entries. */
148
+ export async function asideJournalResponse(
149
+ ctx: ManagementContext, requestedClient: string | null, options: AsideProfileRouteOptions,
150
+ ): Promise<Response | null> {
151
+ if (requestedClient === null && !ctx.url.searchParams.has("profile")) return null;
152
+ if (requestedClient !== null && requestedClient !== "aside") {
153
+ return ctx.url.searchParams.has("profile") ? errorResponse(new ProfileQueryError("profile applies only to Aside"), ctx) : null;
154
+ }
155
+ try {
156
+ const id = profileId(ctx);
157
+ if (id !== undefined && requestedClient !== "aside") throw new ProfileQueryError("profile requires client=aside");
158
+ const input = options.input();
159
+ const aside = await listAsideOperations(input, id);
160
+ const rows = [...aside].sort((a, b) => b.entry.at.localeCompare(a.entry.at));
161
+ const newest = new Map<string, string>();
162
+ const ownerKey = (row: typeof rows[number]) => `${row.entry.clientId}:${row.profileId ?? row.entry.configPath}`;
163
+ for (const row of rows) if (!newest.has(ownerKey(row))) newest.set(ownerKey(row), row.entry.opId);
164
+ const operations = rows.map(row => {
165
+ const { entry, store } = row;
166
+ const snapshot = store.readSnapshot(entry).kind;
167
+ const latest = newest.get(ownerKey(row)) === entry.opId;
168
+ return {
169
+ opId: entry.opId, clientId: entry.clientId, kind: entry.kind, at: entry.at,
170
+ configPath: entry.configPath, snapshot,
171
+ ...(row.profileId !== undefined ? { profileId: row.profileId } : {}),
172
+ undoable: snapshot !== "expired" && latest && row.profileId !== undefined && asideOperationMatchesCurrent(input, row),
173
+ deletable: !latest,
174
+ };
175
+ });
176
+ return jsonResponse({ operations }, 200, ctx.req, ctx.config);
177
+ } catch (error) {
178
+ return requestedClient === null && !ctx.url.searchParams.has("profile") ? null : errorResponse(error, ctx);
179
+ }
180
+ }
181
+
182
+ export async function asideRestoreResponse(
183
+ ctx: ManagementContext, body: { opId: string; confirmDrift?: boolean }, options: AsideProfileRouteOptions,
184
+ ): Promise<Response | null> {
185
+ try {
186
+ validateClientSelector(ctx);
187
+ const id = profileId(ctx);
188
+ const input = options.input();
189
+ const rootEntry = input.store?.findOperation(body.opId);
190
+ if (rootEntry && rootEntry.clientId !== "aside") {
191
+ if (id !== undefined || ctx.url.searchParams.has("client")) throw new ProfileQueryError("client/profile selectors do not match the operation");
192
+ return null;
193
+ }
194
+ const operation = await findAsideOperation(input, body.opId, id);
195
+ if (!operation) {
196
+ if (id === undefined) return null;
197
+ return jsonResponse({ error: "integration operation not found", code: "integration_operation_not_found", opId: body.opId }, 404, ctx.req, ctx.config);
198
+ }
199
+ const result = await restoreAsideProfile(input, { ...body, profileId: operation.profileId });
200
+ return result.ok ? jsonResponse(result, 200, ctx.req, ctx.config) : options.failure(result);
201
+ } catch (error) {
202
+ if (error instanceof ClientPathError && !ctx.url.searchParams.has("profile")
203
+ && options.input().store?.findOperation(body.opId)?.clientId !== "aside") return null;
204
+ return errorResponse(error, ctx);
205
+ }
206
+ }
207
+
208
+ export async function asideJournalDeleteResponse(
209
+ ctx: ManagementContext, opId: string, options: AsideProfileRouteOptions,
210
+ ): Promise<Response | null> {
211
+ try {
212
+ validateClientSelector(ctx);
213
+ const id = profileId(ctx);
214
+ const input = options.input();
215
+ const rootEntry = input.store?.findOperation(opId);
216
+ if (rootEntry && rootEntry.clientId !== "aside") {
217
+ if (id !== undefined || ctx.url.searchParams.has("client")) throw new ProfileQueryError("client/profile selectors do not match the operation");
218
+ return null;
219
+ }
220
+ const operation = await findAsideOperation(input, opId, id);
221
+ if (!operation) {
222
+ if (id === undefined) return null;
223
+ return jsonResponse({ error: "integration operation not found", code: "integration_operation_not_found", opId }, 404, ctx.req, ctx.config);
224
+ }
225
+ return jsonResponse(await deleteAsideOperation(input, { opId, profileId: operation.profileId, principal: ctx.principal ?? "admin-token" }), 200, ctx.req, ctx.config);
226
+ } catch (error) {
227
+ if (error instanceof ClientPathError && !ctx.url.searchParams.has("profile")
228
+ && options.input().store?.findOperation(opId)?.clientId !== "aside") return null;
229
+ return errorResponse(error, ctx);
230
+ }
231
+ }
@@ -1,3 +1,4 @@
1
+ import type { IntegrationClientId } from "../../integrations/registry";
1
2
  import { randomUUID } from "node:crypto";
2
3
  import { readFileSync } from "node:fs";
3
4
  import type { CatalogModel } from "../../codex/catalog";
@@ -8,6 +9,7 @@ import {
8
9
  deleteConfigTopLevelKey,
9
10
  hasOwnProvider,
10
11
  isValidProviderName,
12
+ loadConfig,
11
13
  multiAgentGuidanceEnabled,
12
14
  providerBaseUrlConfigError,
13
15
  providerHeadersConfigError,
@@ -151,10 +153,11 @@ async function sidecarVisionResponseSettings(config: OcxConfig): Promise<{
151
153
 
152
154
  /** One client's outcome from a fan-out sync. Absent from the list means "left alone". */
153
155
  interface ClientIntegrationSyncOutcome {
154
- readonly client: "grok" | "claude-desktop" | "mcode";
156
+ readonly client: "grok" | "claude-desktop" | IntegrationClientId;
155
157
  readonly ok: boolean;
156
158
  readonly changed?: boolean;
157
159
  readonly reason?: string;
160
+ readonly profileId?: number;
158
161
  }
159
162
 
160
163
  /**
@@ -171,9 +174,10 @@ interface ClientIntegrationSyncOutcome {
171
174
  * does not fail the sync: Codex is the one that matters for routing, and a broken Grok file
172
175
  * should surface as a warning, not as a 500 on a command that did its main job.
173
176
  */
174
- async function syncEnabledClientIntegrations(
177
+ export async function syncEnabledClientIntegrations(
175
178
  port: number | undefined,
176
179
  config: OcxConfig,
180
+ deps: Pick<ManagementContext["deps"], "fetchAllModels" | "writeDesktop3pConfig"> = {},
177
181
  ): Promise<ClientIntegrationSyncOutcome[]> {
178
182
  if (port === undefined) return [];
179
183
  const { claudeDesktopIntegrationEnabled, grokIntegrationEnabled } = await import("../../codex/desired-state");
@@ -196,49 +200,40 @@ async function syncEnabledClientIntegrations(
196
200
  const { writeDesktop3pConfig } = await import("../../claude/desktop-3p");
197
201
  const { desktopVisibleNativeSlugs, filterCatalogVisibleModels } = await import("../../codex/catalog");
198
202
  const { fetchAllModels } = await import("../management-api");
199
- const routed = filterCatalogVisibleModels(await fetchAllModels(config), config)
200
- .map(model => ({ provider: model.provider, id: model.id, contextWindow: model.contextWindow }));
201
- const r = writeDesktop3pConfig(
202
- port,
203
- [...desktopVisibleNativeSlugs(config)],
204
- routed,
205
- config.apiKeys?.[0]?.key,
206
- "static",
207
- config.claudeCode?.desktopProfile,
208
- nativeContextLimits(config),
209
- );
210
- out.push(r.written
211
- ? { client: "claude-desktop", ok: true, changed: true }
212
- : { client: "claude-desktop", ok: false, reason: r.reason ?? "Claude Desktop write failed" });
203
+ const models = await (deps.fetchAllModels ?? fetchAllModels)(config);
204
+ // Discovery admits a concurrent OFF or settings edit. Re-read outside C:
205
+ // the writer facade owns L and its final desired-state check under L→C.
206
+ const latest = loadConfig();
207
+ if (claudeDesktopIntegrationEnabled(latest)) {
208
+ const routed = filterCatalogVisibleModels(models, latest)
209
+ .map(model => ({ provider: model.provider, id: model.id, contextWindow: model.contextWindow }));
210
+ const r = (deps.writeDesktop3pConfig ?? writeDesktop3pConfig)(
211
+ port,
212
+ [...desktopVisibleNativeSlugs(latest)],
213
+ routed,
214
+ latest.apiKeys?.[0]?.key,
215
+ "static",
216
+ latest.claudeCode?.desktopProfile,
217
+ nativeContextLimits(latest),
218
+ );
219
+ out.push(r.written
220
+ ? { client: "claude-desktop", ok: true, changed: true }
221
+ : { client: "claude-desktop", ok: false, reason: r.reason ?? "Claude Desktop write failed" });
222
+ }
213
223
  } catch (error) {
214
224
  out.push({ client: "claude-desktop", ok: false, reason: error instanceof Error ? error.message : String(error) });
215
225
  }
216
226
  }
217
227
 
218
- try {
219
- const { refreshOwnedIntegration } = await import("../../integrations/owned-refresh");
220
- const result = await refreshOwnedIntegration({
221
- clientId: "mcode",
222
- models: async () => {
223
- const { loadExportModels } = await import("./model-rows");
224
- return loadExportModels(config);
225
- },
226
- config,
227
- port,
228
- });
229
- if (result) {
230
- out.push(result.ok
231
- ? {
232
- client: "mcode",
233
- ok: true,
234
- changed: result.changed === true,
235
- ...(result.reason ? { reason: result.reason } : {}),
236
- }
237
- : { client: "mcode", ok: false, reason: result.reason });
238
- }
239
- } catch (error) {
240
- out.push({ client: "mcode", ok: false, reason: error instanceof Error ? error.message : String(error) });
241
- }
228
+ const { refreshOwnedCatalogIntegrations } = await import("../../integrations/catalog-refresh");
229
+ out.push(...await refreshOwnedCatalogIntegrations({
230
+ models: async () => {
231
+ const { loadExportModels } = await import("./model-rows");
232
+ return loadExportModels(config);
233
+ },
234
+ config,
235
+ port,
236
+ }, ["mcode", "pi", "aside"]));
242
237
 
243
238
  return out;
244
239
  }
@@ -651,7 +646,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
651
646
  // for the on-demand command. Codex goes first because the others read its catalog.
652
647
  const integrations = result.status === "refused"
653
648
  ? []
654
- : await syncEnabledClientIntegrations(runtime?.port, config);
649
+ : await syncEnabledClientIntegrations(runtime?.port, config, deps);
655
650
  const status = result.status === "refused" ? 409 : (result.status === "skipped" || result.ok ? 200 : 500);
656
651
  return jsonResponse({
657
652
  ...attachStaleAppServerHint(result),
@@ -6,6 +6,7 @@ import type { StartupHealth } from "../../codex/autostart-health";
6
6
  import type { StartupInstallAction } from "../startup-action-control";
7
7
  import type { ManagementPrincipal, ManagementSessionControl } from "../management-auth";
8
8
  import type { CatalogModel } from "../../codex/catalog";
9
+ import type { refreshOwnedCatalogIntegrations } from "../../integrations/catalog-refresh";
9
10
  import type { Paths as CodexPromptPaths } from "../../codex/prompt-layers";
10
11
  import type { injectGrokConfig } from "../../grok/inject";
11
12
  import type { removeDesktop3pStandardPivot, writeDesktop3pConfig } from "../../claude/desktop-3p";
@@ -20,6 +21,8 @@ import type {
20
21
  } from "../../codex/app-server-restart-service";
21
22
 
22
23
  export interface ManagementApiDeps {
24
+ /** Isolates automatic owned-client writes in route tests. */
25
+ refreshOwnedCatalogIntegrations?: typeof refreshOwnedCatalogIntegrations;
23
26
  /** Platform seam for capability projections; does not alter host-level startup behavior. */
24
27
  platform?: NodeJS.Platform;
25
28
  toggleCodexMultiAgentV2?: (enabled: boolean) => void;
@@ -9,6 +9,12 @@
9
9
  * Design of record: devlog/_fin/260802_client_toggle_api/040_wp4_management_api.md.
10
10
  */
11
11
  import { readFileSync } from "node:fs";
12
+ import { saveConfigPreservingClaudeCode } from "../../config";
13
+ import { listAsideProfileStates, type AsideProfilesInput } from "../../integrations/aside-profiles";
14
+ import {
15
+ handleAsideProfileRoutes, asideJournalResponse, asideRestoreResponse,
16
+ asideJournalDeleteResponse, type AsideProfileRouteOptions,
17
+ } from "./aside-profile-routes";
12
18
  import type { IntegrationIO } from "../../integrations/config-io";
13
19
  import { matchesOperationResult } from "../../integrations/journal";
14
20
  import {
@@ -41,6 +47,8 @@ import { loadExportModels } from "./model-rows";
41
47
 
42
48
 
43
49
  const INTEGRATION_ROUTE_PREFIX = "/api/client-integrations/";
50
+ const INTEGRATION_COLLECTION_PATH = "/api/client-integrations";
51
+ const INTEGRATION_HISTORY_PATHS = ["/api/client-integrations/journal", "/api/client-integrations/restore"];
44
52
  export { INTEGRATION_MUTATION_TERMINAL_MS };
45
53
 
46
54
  type IntegrationStateRecord = Awaited<ReturnType<typeof readIntegrationState>>;
@@ -85,6 +93,7 @@ export interface IntegrationJournalRow {
85
93
  * is what a user reaches for right after the mistake.
86
94
  */
87
95
  deletable: boolean;
96
+ profileId?: number;
88
97
  }
89
98
 
90
99
  export interface IntegrationToggleBody {
@@ -181,6 +190,23 @@ function integrationStore(): IntegrationStateStore {
181
190
  return integrationMutationTestHooks?.store ?? createIntegrationStateStore();
182
191
  }
183
192
 
193
+ function asideOptions(ctx: ManagementContext): AsideProfileRouteOptions {
194
+ let input: AsideProfilesInput | undefined;
195
+ return {
196
+ input: () => input ??= {
197
+ config: ctx.config,
198
+ port: Number(ctx.url.port) || ctx.config.port,
199
+ models: () => loadExportModels(ctx.config),
200
+ store: integrationStore(),
201
+ ...pathOverrides(),
202
+ io: integrationMutationTestHooks?.io,
203
+ lockSeams: integrationMutationTestHooks?.lockSeams,
204
+ persistConfig: ctx.deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode,
205
+ },
206
+ failure: result => writerFailureResponse("aside", result, ctx),
207
+ };
208
+ }
209
+
184
210
  async function buildIntegrationWriteInput(
185
211
  clientId: IntegrationClientId,
186
212
  ctx: ManagementContext,
@@ -331,6 +357,8 @@ async function handleJournalDelete(ctx: ManagementContext): Promise<Response> {
331
357
  code: "invalid_op_id",
332
358
  }, 400, req, ctx.config);
333
359
  }
360
+ const aside = await asideJournalDeleteResponse(ctx, opId, asideOptions(ctx));
361
+ if (aside) return aside;
334
362
  try {
335
363
  const store = integrationStore();
336
364
  const operation = store.findOperation(opId);
@@ -392,6 +420,14 @@ async function handleJournalDelete(ctx: ManagementContext): Promise<Response> {
392
420
 
393
421
  export async function handleIntegrationRoutes(ctx: ManagementContext): Promise<Response | null> {
394
422
  const { req, url } = ctx;
423
+ const profileOptions = asideOptions(ctx);
424
+ const aside = await handleAsideProfileRoutes(ctx, profileOptions);
425
+ if (aside) return aside;
426
+ if (url.searchParams.has("profile")
427
+ && (url.pathname === INTEGRATION_COLLECTION_PATH || url.pathname.startsWith(INTEGRATION_ROUTE_PREFIX))
428
+ && !INTEGRATION_HISTORY_PATHS.includes(url.pathname)) {
429
+ return jsonResponse({ error: "profile applies only to Aside", code: "invalid_aside_profile" }, 400, req, ctx.config);
430
+ }
395
431
 
396
432
  if (url.pathname === "/api/client-integrations" && req.method === "GET") {
397
433
  try {
@@ -408,8 +444,13 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise<R
408
444
  * later one silently — a duplicate that could only ever hide a
409
445
  * disagreement, never surface it.
410
446
  */
411
- const clients = INTEGRATION_CLIENT_IDS.map(clientId =>
412
- readIntegrationState({ clientId, models, config: ctx.config, port, store, ...pathOverrides() }));
447
+ const clients = await Promise.all(INTEGRATION_CLIENT_IDS.map(async clientId => {
448
+ if (clientId === "aside") {
449
+ try { return await listAsideProfileStates({ ...profileOptions.input(), models }); }
450
+ catch { /* Existing status projection retains a safe unresolved-path diagnostic. */ }
451
+ }
452
+ return readIntegrationState({ clientId, models, config: ctx.config, port, store, ...pathOverrides() });
453
+ }));
413
454
  return jsonResponse({ clients } satisfies IntegrationStateListEnvelope, 200, req, ctx.config);
414
455
  } catch (error) {
415
456
  return internalErrorResponse(error, ctx);
@@ -426,6 +467,8 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise<R
426
467
  if (requestedClient !== null && !isIntegrationClientId(requestedClient)) {
427
468
  return invalidClientResponse(ctx);
428
469
  }
470
+ const asideJournal = await asideJournalResponse(ctx, requestedClient, profileOptions);
471
+ if (asideJournal) return asideJournal;
429
472
  try {
430
473
  const store = integrationStore();
431
474
  const storedOperations = store.listOperations(requestedClient ?? undefined);
@@ -435,7 +478,7 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise<R
435
478
  newestByClient.set(operation.clientId, operation.opId);
436
479
  }
437
480
  }
438
- const operations: IntegrationJournalRow[] = storedOperations.map(operation => {
481
+ let operations: IntegrationJournalRow[] = storedOperations.map(operation => {
439
482
  /*
440
483
  * Resolved against the DISK, not read off the row.
441
484
  *
@@ -481,6 +524,14 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise<R
481
524
  deletable: newestByClient.get(operation.clientId) !== operation.opId,
482
525
  };
483
526
  });
527
+ if (requestedClient === null) {
528
+ const profiles = await asideJournalResponse(ctx, "aside", profileOptions);
529
+ if (profiles?.ok) {
530
+ const body = await profiles.json() as IntegrationJournalEnvelope;
531
+ operations = [...operations.filter(row => row.clientId !== "aside"), ...body.operations]
532
+ .sort((a, b) => b.at.localeCompare(a.at));
533
+ }
534
+ }
484
535
  return jsonResponse({ operations } satisfies IntegrationJournalEnvelope, 200, req, ctx.config);
485
536
  } catch (error) {
486
537
  return internalErrorResponse(error, ctx);
@@ -506,6 +557,8 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise<R
506
557
 
507
558
  const opId = parsed.opId.trim();
508
559
  const confirmDrift = parsed.confirmDrift ?? false;
560
+ const asideRestore = await asideRestoreResponse(ctx, { opId, confirmDrift }, profileOptions);
561
+ if (asideRestore) return asideRestore;
509
562
  let restoreClientId: IntegrationClientId | undefined;
510
563
  try {
511
564
  const store = integrationStore();
@@ -570,6 +623,11 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise<R
570
623
  const requestedClient = decodeClientPath(url.pathname);
571
624
  if (requestedClient === null) return null;
572
625
  if (!isIntegrationClientId(requestedClient)) return invalidClientResponse(ctx);
626
+ // Canonical Aside paths are owned above. Never decode an alternate spelling
627
+ // into the legacy single-account writer or bypass profile policy/guards.
628
+ if (requestedClient === "aside") {
629
+ return jsonResponse({ error: "Use the canonical Aside profile path", code: "invalid_aside_profile_path" }, 400, req, ctx.config);
630
+ }
573
631
 
574
632
  if (req.method === "GET") {
575
633
  try {
@@ -111,6 +111,7 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
111
111
  const logs = filterRequestLogs(all, url.searchParams);
112
112
  return jsonResponse({
113
113
  timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
114
+ generatedAt: Date.now(),
114
115
  total,
115
116
  logs: logs.map(requestLogDto),
116
117
  });
@@ -183,6 +183,17 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
183
183
  // bypass this seam with a dynamic config import — doing so replaced a user's
184
184
  // ~/.opencodex/config.json with the `existing-uuid` test fixture.
185
185
  const persistConfig = deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode;
186
+ const convergeVisibleCatalogs = async () => {
187
+ const catalogRefresh = await convergeCodexCatalog();
188
+ const refresh = deps.refreshOwnedCatalogIntegrations
189
+ ?? (await import("../../integrations/catalog-refresh")).refreshOwnedCatalogIntegrations;
190
+ const clientIntegrations = await refresh({
191
+ config,
192
+ port: Number(url.port) || config.port,
193
+ models: () => loadExportModels(config),
194
+ });
195
+ return { catalogRefresh, clientIntegrations };
196
+ };
186
197
 
187
198
  if (url.pathname === "/api/model-discovery" && req.method === "GET") {
188
199
  const providers = Object.fromEntries(Object.entries(config.providers).map(([name, provider]) => [
@@ -518,8 +529,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
518
529
  const disabled = Array.isArray(body.models) ? body.models.filter((m): m is string => typeof m === "string") : [];
519
530
  config.disabledModels = disabled;
520
531
  persistConfig(config);
521
- const catalogRefresh = await convergeCodexCatalog();
522
- return jsonResponse({ ok: true, disabled, catalogRefresh });
532
+ return jsonResponse({ ok: true, disabled, ...await convergeVisibleCatalogs() });
523
533
  }
524
534
 
525
535
  // One user-facing visibility switch spans two persisted filters: a provider allowlist and the
@@ -566,7 +576,10 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
566
576
  }
567
577
  const id = value.id.trim();
568
578
  const native = value.native === true;
569
- if (!id || (provider === "openai") !== native || (native && !supportedNative.has(id))) {
579
+ const configuredOpenAiCustom = provider === "openai" && !native && providerConfig
580
+ && (config.customModels ?? []).some(model => model.provider === provider && model.modelId === id);
581
+ if (!id || (native && (provider !== "openai" || !supportedNative.has(id)))
582
+ || (provider === "openai" && !native && !configuredOpenAiCustom)) {
570
583
  return jsonResponse({ error: "invalid model visibility target" }, 400);
571
584
  }
572
585
  const key = `${native ? "native" : "routed"}:${id}`;
@@ -641,8 +654,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
641
654
 
642
655
  config.disabledModels = disabled;
643
656
  persistConfig(config);
644
- const catalogRefresh = await convergeCodexCatalog();
645
- return jsonResponse({ ok: true, scope, provider, enabled: body.enabled, disabled, catalogRefresh });
657
+ return jsonResponse({ ok: true, scope, provider, enabled: body.enabled, disabled, ...await convergeVisibleCatalogs() });
646
658
  }
647
659
 
648
660
  if (url.pathname === "/api/custom-models" && req.method === "GET") {
@@ -852,7 +864,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
852
864
  delete target.selectedModels;
853
865
  delete target.modelPreset;
854
866
  persistConfig(config);
855
- return jsonResponse({ ok: true, provider, mode, selected: [], catalogRefresh: await convergeCodexCatalog() });
867
+ return jsonResponse({ ok: true, provider, mode, selected: [], ...await convergeVisibleCatalogs() });
856
868
  }
857
869
  if (mode === "custom") {
858
870
  // Keep whatever is selected; only the marker changes, so a user can pin their edits
@@ -897,7 +909,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
897
909
  mode: "preset",
898
910
  appliedVersion: preset.version,
899
911
  selected: presetIds,
900
- catalogRefresh: await convergeCodexCatalog(),
912
+ ...await convergeVisibleCatalogs(),
901
913
  });
902
914
  }
903
915
  if (url.pathname === "/api/selected-models" && req.method === "PUT") {
@@ -921,8 +933,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
921
933
  // re-materialize over it afterwards.
922
934
  markModelPresetDiverged(config.providers[provider]);
923
935
  persistConfig(config);
924
- const catalogRefresh = await convergeCodexCatalog();
925
- return jsonResponse({ ok: true, provider, selected: models, catalogRefresh });
936
+ return jsonResponse({ ok: true, provider, selected: models, ...await convergeVisibleCatalogs() });
926
937
  }
927
938
  return null;
928
939
  }
@@ -11,6 +11,7 @@
11
11
  import type { CatalogModel } from "../../codex/catalog";
12
12
  import {
13
13
  catalogModelSlug,
14
+ filterCatalogVisibleModels,
14
15
  accountBoundNativeOpenAiSlugsBySelector,
15
16
  nativeDefaultReasoningEffort,
16
17
  NATIVE_OPENAI_MODELS,
@@ -169,7 +170,11 @@ export async function listManagementModelRows(
169
170
  ...(contextCap !== undefined ? { contextCap, contextCapped: m.contextCapped === true } : {}),
170
171
  };
171
172
  }).filter((row): row is ManagementModelRow => row !== null);
172
- const rows = [...native, ...dedupedRouted, ...visibleCustomModels];
173
+ // Manual OpenAI rows retain their routed selector but replace the bare dashboard row.
174
+ // Account-qualified rows remain distinct, explicitly selected routes.
175
+ const visibleNative = native.filter(model => model.id.includes("/")
176
+ || !customNamespaced.has(routedSlug(model.provider, model.id)));
177
+ const rows = [...visibleNative, ...dedupedRouted, ...visibleCustomModels];
173
178
  // Include disabled rows and configured aliases before the export visibility filter:
174
179
  // a hidden real `x--fast` must never become a synthetic selector for another model.
175
180
  const knownIds = config.fastRows === false ? new Set<string>() : knownEffortRowIds(config);
@@ -213,5 +218,8 @@ export function toExportModel(row: ManagementModelRow): ExportModel {
213
218
  */
214
219
  export async function loadExportModels(config: OcxConfig): Promise<ExportModel[]> {
215
220
  const rows = await listManagementModelRows(config);
216
- return rows.filter(row => !row.disabled).map(toExportModel);
221
+ // Management deliberately lists the full roster so hidden models can be enabled.
222
+ // A client picker must also honor the provider selection, not just its blocklist.
223
+ const visibleRouted = new Set(filterCatalogVisibleModels(rows.filter(row => !row.native), config));
224
+ return rows.filter(row => !row.disabled && (row.native || visibleRouted.has(row))).map(toExportModel);
217
225
  }
@@ -129,6 +129,11 @@ function validateKeyName(
129
129
  export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<Response | null> {
130
130
  const { req, url, config, deps, syncClaudeAgentDefsBestEffort } = ctx;
131
131
 
132
+ if (url.pathname === "/api/accounts/events" && req.method === "GET") {
133
+ const { accountSelectionStream } = await import("./account-selection-stream");
134
+ return accountSelectionStream(req, () => ctx.sessionControl?.isCurrent(req, config) === true);
135
+ }
136
+
132
137
  // Which providers support real OAuth login (drives the GUI's "Log in with …" buttons).
133
138
  if (url.pathname === "/api/oauth/providers" && req.method === "GET") {
134
139
  return jsonResponse({ providers: listOAuthProviders() });
@@ -320,6 +325,8 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
320
325
  if (!body.accountId) return jsonResponse({ error: "missing accountId" }, 400);
321
326
  const { setActiveAccount } = await import("../../oauth/store");
322
327
  if (!(await setActiveAccount(provider, body.accountId))) return jsonResponse({ error: "account not found" }, 404);
328
+ const { forgetGenericFailoverRoster } = await import("../../oauth/generic-account-failover");
329
+ forgetGenericFailoverRoster(provider);
323
330
  if (provider === "anthropic") {
324
331
  const { resetAnthropicRoutingForManualSelection } = await import("../../oauth/anthropic-routing");
325
332
  resetAnthropicRoutingForManualSelection(body.accountId);