Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Depends on 010 filtered loader. Loop spec-satisfaction repair. Goal: a model visibility/selection change and explicit sync refresh existing connected Pi/Aside files. No adoption of unowned/manual files, no recreation of removed blocks, no override of drift.

NEW src/integrations/catalog-refresh.ts: bounded helper refreshOwnedCatalogIntegrations(input) iterates explicit client list [pi, aside] (sync may include mcode), passes lazy cached models loader to refreshOwnedIntegration, catches per-client errors and returns existing outcome shape. Use existing ownership store, mutation flight and coordinated writer; never bypass fingerprints.
NEW src/integrations/catalog-refresh.ts: bounded helper refreshOwnedCatalogIntegrations(input, clientIds) defaults clientIds to [pi, aside]; callers may supply an explicit list including mcode. It passes a lazy cached models loader to refreshOwnedIntegration, catches per-client errors and returns existing outcome shape. Use existing ownership store, mutation flight and coordinated writer; never bypass fingerprints. The later Aside-profile layer delegates Aside to its server-owned profile engine; direct CLI sync passes [mcode, pi] here and invokes the Aside server helper once separately, including an explicit unavailable-server diagnostic.
Comment thread
lidge-jun marked this conversation as resolved.
MODIFY src/server/management/model-routes.ts: local async convergence helper calls existing convergeCodexCatalog then new owned refresh for pi/aside with port from URL/config and lazy loadExportModels(config); attach clientIntegrations outcome to disabled-models, model-visibility, selected-models and model-preset writes. Keep successful config persistence even when one file refuses refresh; return warning outcome.
MODIFY src/server/management/config-routes.ts and src/cli/dispatch.ts: expand current MCode-only owned refresh to mcode/pi/aside via helper; preserve native Grok/Desktop gates and refused-sync behavior.
MODIFY existing tests/clients/sync-client-integrations.test.ts and tests/server/management-integration-routes.test.ts: fake IO/store or isolated home seeds owned pi/aside with two models, refresh with selected one, assert hidden row removed and other provider fields preserved. Prove unowned, removed and drifted configs untouched; one failure does not block other client. Add route-driven visibility refresh coverage using injected convergence.
Expand All @@ -15,3 +15,13 @@ Verification: standalone isolated writer probe using synthetic models and temp h
The existing constant refresh mutation-flight key incorrectly joins different model selections. MODIFY src/integrations/owned-refresh.ts to use a unique per-refresh operation key (crypto.randomUUID), making overlapping refreshes explicitly busy rather than reporting another desired catalog as success. Implicit refresh never joins an explicit HTTP mutation. Add controlled overlap with distinct old/new rosters: second call reports integration_mutation_busy; first result describes only its own write. Subsequent retry applies the new roster. Return per-client failures; never retry stale snapshots automatically.

Add a ManagementApiDeps refreshOwnedCatalogIntegrations seam for route verification, defaulting to the real helper. Creation: exported helper/deps type; consumption: model routes and explicit sync. No serialization/deserialization: runtime-only dependency injection. Tests use fake IO/store or temporary home, never actual user-owned files.

## P revalidation and implementation interface

010 b8010aebd passes the four standalone visibility probes and source review; all original hosted-CI/merge criteria are retained under the terminal stack cycle, not marked complete. Helper signature: refreshOwnedCatalogIntegrations(input: Omit<OwnedIntegrationRefreshInput, "clientId">, clientIds: readonly IntegrationClientId[] = ["pi", "aside"]): Promise<OwnedIntegrationRefreshOutcome[]>. Memoize the lazy model load per fan-out; no owned record means no catalog load. Catch and redact each failure. Explicit sync passes [mcode,pi,aside]. Visibility routes attach both catalogRefresh and clientIntegrations; native Codex failure does not undo an already persisted selection.

Delegate tests only to one worker: tests/clients/sync-client-integrations.test.ts owns helper refresh+overlap coverage; main owns implementation and route regression tests. The worker has no production writes, suite execution, FSM or git mutations.

## Implementation audit synthesis

Averroes found an indirect source-oracle dependency: codex-convergence-contract.test.ts counts direct convergence calls and two preset calls. The shared visibility helper changes direct count but preserves fourteen logical paths. Update the inventory to subtract the helper definition and add its five callers, assert exactly one Codex convergence inside the helper, and preserve the marker-only custom preset negative. Run that affected file remotely in addition to the writer/route tests. No runtime blockers in the ownership audit.
9 changes: 6 additions & 3 deletions docs-site/src/content/docs/guides/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,9 +218,12 @@ ocx integration client enable --client mcode
ocx mcode
```

Once connected, `ocx sync` also refreshes the owned MCode block with current context
windows and reasoning-effort ladders. It leaves missing, foreign-edited, unsafe, and
never-owned blocks untouched; re-enable explicitly when you intend to reconnect one.
Once connected, `ocx sync` refreshes owned MCode, Pi, and Aside catalogs with the current
model selection, context windows, and reasoning-effort ladders. Changes to model visibility,
provider selection, or presets also refresh connected Pi and Aside catalogs. Missing,
foreign-edited, unsafe, and never-owned blocks stay untouched; reconnect them explicitly.
A refused or overlapping refresh is reported separately for each client. Start a new Pi
session or fully quit and reopen Aside to load the updated file.

The separate MiniMax platform CLI (`mmx`) is not a file-toggle integration. Its text
commands use MiniMax's Anthropic-compatible endpoint, so OpenCodex provides a
Expand Down
17 changes: 9 additions & 8 deletions src/cli/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -387,25 +387,26 @@ const commandRunners: Record<string, CommandRunner> = {
if (restartDesktopApp) await handleDesktopAppRestart(console);
}
// `ocx sync` is a direct CLI path; it does not call the management
// `/api/sync` route. Refresh the already-connected MCode block here too,
// `/api/sync` route. Refresh already-connected file integrations here too,
// after Codex has published the catalog that supplies its capabilities.
if (synced.status !== "refused" && live) {
try {
const config = deps.loadConfig();
const { refreshOwnedIntegration } = await import("../integrations/owned-refresh");
const result = await refreshOwnedIntegration({
clientId: "mcode",
const { refreshOwnedCatalogIntegrations } = await import("../integrations/catalog-refresh");
const results = await refreshOwnedCatalogIntegrations({
models: async () => {
const { loadExportModels } = await import("../server/management/model-rows");
return loadExportModels(config);
},
config,
port: live.port,
});
if (result?.changed) console.log("MCode integration refreshed from the current catalog.");
else if (result?.reason) console.warn(`MCode integration was not refreshed: ${result.reason}`);
}, ["mcode", "pi", "aside"]);
for (const result of results) {
if (result.changed) console.log(`${result.client} integration refreshed from the current catalog.`);
else if (result.reason) console.warn(`${result.client} integration was not refreshed: ${result.reason}`);
}
} catch (error) {
console.warn(`MCode integration was not refreshed: ${error instanceof Error ? error.message : String(error)}`);
console.warn(`Client integrations were not refreshed: ${error instanceof Error ? error.message : String(error)}`);
}
}
return code;
Expand Down
32 changes: 32 additions & 0 deletions src/integrations/catalog-refresh.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { redactSecretString } from "../lib/redact";
import type { ExportModel } from "../clients/config-export";
import type { IntegrationClientId } from "./registry";
import {
refreshOwnedIntegration,
type OwnedIntegrationRefreshInput,
type OwnedIntegrationRefreshOutcome,
} from "./owned-refresh";

/** Refresh only previously connected clients; a refused file never blocks its peers. */
export async function refreshOwnedCatalogIntegrations(
input: Omit<OwnedIntegrationRefreshInput, "clientId">,
clientIds: readonly IntegrationClientId[] = ["pi", "aside"],
): Promise<OwnedIntegrationRefreshOutcome[]> {
let models: Promise<readonly ExportModel[]> | undefined;
const loadModels = () => models ??= Promise.resolve().then(() =>
typeof input.models === "function" ? input.models() : input.models);
const outcomes: OwnedIntegrationRefreshOutcome[] = [];
for (const clientId of clientIds) {
try {
const result = await refreshOwnedIntegration({ ...input, clientId, models: loadModels });
if (result) outcomes.push(result);
} catch (error) {
outcomes.push({
client: clientId,
ok: false,
reason: redactSecretString(error instanceof Error ? error.message : String(error)),
});
}
}
return outcomes;
}
4 changes: 3 additions & 1 deletion src/integrations/owned-refresh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ export async function refreshOwnedIntegration(
const bound = { ...rest, models, store };
const result = await runIntegrationMutationFlight(
input.clientId,
"refresh",
// Separate catalog snapshots must not inherit another refresh's success.
// The shared flight owner returns busy for overlapping operations instead.
`refresh:${crypto.randomUUID()}`,
input.io?.now ?? Date.now,
() => refreshIntegrationCoordinated(bound, options),
);
Expand Down
36 changes: 11 additions & 25 deletions src/server/management/config-routes.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { IntegrationClientId } from "../../integrations/registry";
import { randomUUID } from "node:crypto";
import { readFileSync } from "node:fs";
import type { CatalogModel } from "../../codex/catalog";
Expand Down Expand Up @@ -151,7 +152,7 @@ async function sidecarVisionResponseSettings(config: OcxConfig): Promise<{

/** One client's outcome from a fan-out sync. Absent from the list means "left alone". */
interface ClientIntegrationSyncOutcome {
readonly client: "grok" | "claude-desktop" | "mcode";
readonly client: "grok" | "claude-desktop" | IntegrationClientId;
readonly ok: boolean;
readonly changed?: boolean;
readonly reason?: string;
Expand Down Expand Up @@ -215,30 +216,15 @@ async function syncEnabledClientIntegrations(
}
}

try {
const { refreshOwnedIntegration } = await import("../../integrations/owned-refresh");
const result = await refreshOwnedIntegration({
clientId: "mcode",
models: async () => {
const { loadExportModels } = await import("./model-rows");
return loadExportModels(config);
},
config,
port,
});
if (result) {
out.push(result.ok
? {
client: "mcode",
ok: true,
changed: result.changed === true,
...(result.reason ? { reason: result.reason } : {}),
}
: { client: "mcode", ok: false, reason: result.reason });
}
} catch (error) {
out.push({ client: "mcode", ok: false, reason: error instanceof Error ? error.message : String(error) });
}
const { refreshOwnedCatalogIntegrations } = await import("../../integrations/catalog-refresh");
out.push(...await refreshOwnedCatalogIntegrations({
models: async () => {
const { loadExportModels } = await import("./model-rows");
return loadExportModels(config);
},
config,
port,
}, ["mcode", "pi", "aside"]));

return out;
}
Expand Down
3 changes: 3 additions & 0 deletions src/server/management/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { StartupHealth } from "../../codex/autostart-health";
import type { StartupInstallAction } from "../startup-action-control";
import type { ManagementPrincipal, ManagementSessionControl } from "../management-auth";
import type { CatalogModel } from "../../codex/catalog";
import type { refreshOwnedCatalogIntegrations } from "../../integrations/catalog-refresh";
import type { Paths as CodexPromptPaths } from "../../codex/prompt-layers";
import type { injectGrokConfig } from "../../grok/inject";
import type { removeDesktop3pStandardPivot, writeDesktop3pConfig } from "../../claude/desktop-3p";
Expand All @@ -20,6 +21,8 @@ import type {
} from "../../codex/app-server-restart-service";

export interface ManagementApiDeps {
/** Isolates automatic owned-client writes in route tests. */
refreshOwnedCatalogIntegrations?: typeof refreshOwnedCatalogIntegrations;
/** Platform seam for capability projections; does not alter host-level startup behavior. */
platform?: NodeJS.Platform;
toggleCodexMultiAgentV2?: (enabled: boolean) => void;
Expand Down
24 changes: 16 additions & 8 deletions src/server/management/model-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,17 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
// bypass this seam with a dynamic config import — doing so replaced a user's
// ~/.opencodex/config.json with the `existing-uuid` test fixture.
const persistConfig = deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode;
const convergeVisibleCatalogs = async () => {
const catalogRefresh = await convergeCodexCatalog();
const refresh = deps.refreshOwnedCatalogIntegrations
?? (await import("../../integrations/catalog-refresh")).refreshOwnedCatalogIntegrations;
const clientIntegrations = await refresh({
config,
port: Number(url.port) || config.port,
models: () => loadExportModels(config),
});
return { catalogRefresh, clientIntegrations };
};

if (url.pathname === "/api/model-discovery" && req.method === "GET") {
const providers = Object.fromEntries(Object.entries(config.providers).map(([name, provider]) => [
Expand Down Expand Up @@ -518,8 +529,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
const disabled = Array.isArray(body.models) ? body.models.filter((m): m is string => typeof m === "string") : [];
config.disabledModels = disabled;
persistConfig(config);
const catalogRefresh = await convergeCodexCatalog();
return jsonResponse({ ok: true, disabled, catalogRefresh });
return jsonResponse({ ok: true, disabled, ...await convergeVisibleCatalogs() });
}

// One user-facing visibility switch spans two persisted filters: a provider allowlist and the
Expand Down Expand Up @@ -644,8 +654,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons

config.disabledModels = disabled;
persistConfig(config);
const catalogRefresh = await convergeCodexCatalog();
return jsonResponse({ ok: true, scope, provider, enabled: body.enabled, disabled, catalogRefresh });
return jsonResponse({ ok: true, scope, provider, enabled: body.enabled, disabled, ...await convergeVisibleCatalogs() });
}

if (url.pathname === "/api/custom-models" && req.method === "GET") {
Expand Down Expand Up @@ -855,7 +864,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
delete target.selectedModels;
delete target.modelPreset;
persistConfig(config);
return jsonResponse({ ok: true, provider, mode, selected: [], catalogRefresh: await convergeCodexCatalog() });
return jsonResponse({ ok: true, provider, mode, selected: [], ...await convergeVisibleCatalogs() });
}
if (mode === "custom") {
// Keep whatever is selected; only the marker changes, so a user can pin their edits
Expand Down Expand Up @@ -900,7 +909,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
mode: "preset",
appliedVersion: preset.version,
selected: presetIds,
catalogRefresh: await convergeCodexCatalog(),
...await convergeVisibleCatalogs(),
});
}
if (url.pathname === "/api/selected-models" && req.method === "PUT") {
Expand All @@ -924,8 +933,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
// re-materialize over it afterwards.
markModelPresetDiverged(config.providers[provider]);
persistConfig(config);
const catalogRefresh = await convergeCodexCatalog();
return jsonResponse({ ok: true, provider, selected: models, catalogRefresh });
return jsonResponse({ ok: true, provider, selected: models, ...await convergeVisibleCatalogs() });
}
return null;
}
9 changes: 9 additions & 0 deletions structure/09_client-integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,15 @@ serialization: provider selections, disabled models, and pending initial selecti
the client roster. The full management list remains available for selection. Native rows retain
their existing visibility rules.

## Owned catalog convergence

Visibility, selected-model and preset writes refresh already-owned Pi/Aside contributions after
persisting the selection. Explicit sync refreshes MCode, Pi and Aside. The shared catalog-refresh
fan-out loads the filtered roster lazily once, leaves unowned clients alone, and reports each
refusal independently. Existing coordinated writers retain all no-clobber and ownership checks.
Implicit refresh operations use distinct flight keys: overlapping desired catalogs return busy
rather than joining a write of a different catalog and reporting false success.

## Fast model selectors

The serving proxy resolves `fastRowAvailable` on every management model row, including its
Expand Down
Loading
Loading