Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions docs-site/src/content/docs/guides/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,8 +263,8 @@ When both `tierModels.haiku` and `smallFastModel` are absent, OpenCodex leaves b

## Roster agents (injectAgents)

`ocx claude` (and the system-env daemon) syncs your featured subagent roster (Subagents tab,
up to 5 models) plus `ocx-self` into `~/.claude/agents/ocx-*.md`.
Proxy startup/ensure, `ocx claude`, and relevant dashboard saves sync your featured subagent roster
(Subagents tab, up to 5 models) plus `ocx-self` into `~/.claude/agents/ocx-*.md`.

- **`ocx-self`** pins your `/model` picker default (falling back to `claudeCode.model`); omitted
when neither exists. It does NOT use model inheritance.
Expand All @@ -276,7 +276,8 @@ up to 5 models) plus `ocx-self` into `~/.claude/agents/ocx-*.md`.
overwritten or pruned; your own agents are never touched.
- Files are atomically synced per file (write + rename).
- `enabled: false` or `injectAgents: false` prunes all verified-owned definitions.
- GUI PUT and roster changes resync immediately; launcher/system-env sync at launch.
- GUI PUT and roster changes resync immediately; every foreground or background proxy start/ensure
reconciles the owned files before a later Claude Code launch reads them.

Dispatch: `subagent_type: "ocx-gpt-5-6-sol"`. 1M-capable targets carry `[1m]` automatically.

Expand Down
2 changes: 1 addition & 1 deletion docs-site/src/content/docs/ja/guides/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ Claude ページで圧縮値を調整できます。**警告:** モデルの実

## ロスターエージェント(injectAgents)

`ocx claude` とシステム環境デーモンは推奨サブエージェントロスター(Subagents タブ、最大 5 モデル)と
プロキシの起動/ensure、`ocx claude`、関連するダッシュボード保存は推奨サブエージェントロスター(Subagents タブ、最大 5 モデル)と
`ocx-self` を `~/.claude/agents/ocx-*.md` に同期します。

- **`ocx-self`** は `/model` ピッカーのデフォルトを固定し、値がない場合は `claudeCode.model` を使います。
Expand Down
2 changes: 1 addition & 1 deletion docs-site/src/content/docs/ko/guides/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ Claude 페이지에서 압축 값을 조절할 수 있어요. **경고:** 모델

## 로스터 에이전트(injectAgents)

`ocx claude`와 시스템 환경 데몬은 추천 서브에이전트 로스터(Subagents 탭, 최대 5개 모델)와
프록시 시작/ensure, `ocx claude`, 관련 대시보드 저장은 추천 서브에이전트 로스터(Subagents 탭, 최대 5개 모델)와
`ocx-self`를 `~/.claude/agents/ocx-*.md`에 동기화해요.

- **`ocx-self`**는 `/model` 선택기의 기본값을 고정하고, 값이 없으면 `claudeCode.model`을 사용해요.
Expand Down
2 changes: 1 addition & 1 deletion docs-site/src/content/docs/ru/guides/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ smallFastModel`; оно подставляется в обе переменны

## Агенты из ростера (injectAgents)

`ocx claude` (и демон системного окружения) синхронизирует ваш ростер избранных подагентов
Запуск/ensure прокси, `ocx claude` и сохранение связанных настроек в панели синхронизируют ваш ростер избранных подагентов
(вкладка Subagents, до 5 моделей) плюс `ocx-self` в `~/.claude/agents/ocx-*.md`.

- **`ocx-self`** закрепляет модель по умолчанию из селектора `/model` (с откатом на
Expand Down
2 changes: 1 addition & 1 deletion docs-site/src/content/docs/zh-cn/guides/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ v1 别名按字面解码(历史上 model ID 中包含的两字符序列 `~s` /

## 名册代理(injectAgents)

`ocx claude`(以及系统环境守护进程)会把你的精选子代理名册(Subagents 标签页,最多 5 个模型)
代理启动/ensure、`ocx claude` 和相关的控制面板保存会把你的精选子代理名册(Subagents 标签页,最多 5 个模型)
和 `ocx-self` 同步到 `~/.claude/agents/ocx-*.md`。

- **`ocx-self`** 固定你在 `/model` 选择器中的默认模型(回退到 `claudeCode.model`);两者均
Expand Down
73 changes: 73 additions & 0 deletions src/cli/claude-agent-startup-sync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import type { OcxConfig } from "../types";
import { injectClaudeAgentDefs } from "../claude/agents-inject";
import { fetchClaudeContextWindows } from "./claude";
import type { ReadinessGate } from "../server/readiness";

export interface ClaudeAgentStartupSyncDeps {
fetchContextWindows?: typeof fetchClaudeContextWindows;
injectAgentDefs?: typeof injectClaudeAgentDefs;
warn?: (message: string) => void;
}

/**
* Keep the public readiness gate pending until both startup reconciliations have settled.
*
* The Codex sync remains the authority for ready versus failed. Claude roster repair is
* deliberately best-effort (#2200), but readiness must not become observable between the
* Codex write and that repair: a service manager could otherwise launch Claude Code against
* stale `ocx-*.md` files. A small forwarding gate delays only the successful transition;
* terminal Codex failure is still published immediately.
*/
export async function reconcileClientStartupBeforeReady<T>(
readinessGate: ReadinessGate,
syncCodex: (deferredGate: ReadinessGate) => Promise<T>,
syncClaudeRoster: () => Promise<unknown>,
): Promise<T> {
let codexReady = false;
const deferredGate: ReadinessGate = {
getStatus: () => readinessGate.getStatus(),
markReady: () => { codexReady = true; },
markFailed: () => readinessGate.markFailed(),
};

const result = await syncCodex(deferredGate);
await syncClaudeRoster();
if (codexReady) readinessGate.markReady();
return result;
}

/**
* Reconcile the generated Claude Code roster after the proxy listener is live.
*
* This belongs to the owning CLI lifecycle rather than `startServer`: the latter is also a
* library/test primitive and must not mutate a developer's real `~/.claude` directory merely
* because an in-process test server was created. The live Management API supplies the same bounded
* context-window map used by `ocx claude`; failure keeps startup available and falls back to an
* unmarked roster. Disabled integrations skip discovery and prune verified-owned definitions.
*/
export async function syncClaudeAgentDefsAtProxyStartup(
config: OcxConfig,
port: number,
deps: ClaudeAgentStartupSyncDeps = {},
): Promise<string[] | null> {
const inject = deps.injectAgentDefs ?? injectClaudeAgentDefs;
const warn = deps.warn ?? (message => console.warn(message));

try {
if (config.claudeCode?.enabled === false || config.claudeCode?.injectAgents === false) {
return inject(config, {});
}

let windows: Record<string, number> = {};
try {
windows = await (deps.fetchContextWindows ?? fetchClaudeContextWindows)(config, port);
} catch {
// Startup remains best-effort. The next management mutation or `ocx claude` launch can
// restore context markers after a transient catalog/Management API failure.
}
return inject(config, windows);
} catch (error) {
warn(`⚠ Claude agent definitions could not be synced at proxy startup: ${error instanceof Error ? error.message : String(error)}`);
return null;
}
}
27 changes: 20 additions & 7 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ import { scheduleCatalogPrewarm } from "./catalog-prewarm";
import { maybeShowUpdatePrompt } from "../update/notify";
import { syncModelsToCodex } from "../codex/sync";
import { setIntegrationEnabled, shouldSyncCodexOnStart, shouldSyncGrokOnStart, syncCodexOnStartIfEnabled } from "../codex/desired-state";
import {
reconcileClientStartupBeforeReady,
syncClaudeAgentDefsAtProxyStartup,
} from "./claude-agent-startup-sync";

/**
* A failed shell-hook reconcile is not cosmetic: a stale hook keeps sourcing
Expand Down Expand Up @@ -381,14 +385,18 @@ async function handleStart(options: { block?: boolean } = {}) {
// The hook is useful only for an installed Claude Code CLI. Reconcile instead of
// appending unconditionally so stale OpenCodex-owned hooks are removed as well.
reportShellHookFailure(reconcileShellHook(systemEnv.injected));

await maybeShowStarPrompt(); // once-only Yes/No GitHub-star prompt on first interactive start
// Post-startup sync drives the readiness gate AND the #1046 stale app-server
// warning. `syncCodexOnStartIfEnabled` respects the Codex integration toggle
// (OFF → no sync) and reports whether anything was written; the readiness gate
// observes the real sync outcome (ok/warning) so /readyz never advertises a
// half-synced proxy as ready while /healthz stays live.
const startupSync = await syncCodexOnStartIfEnabled(port, config, undefined, readinessGate);
// Codex sync owns the ready/failed verdict, but its successful transition is
// deferred until the best-effort Claude roster reconciliation settles. This
// keeps /readyz closed across both startup writes without making an optional
// Claude integration failure prevent the proxy from starting.
const startupSync = await reconcileClientStartupBeforeReady(
readinessGate,
gate => syncCodexOnStartIfEnabled(port, config, undefined, gate),
() => systemEnv.injected
? Promise.resolve(null)
: syncClaudeAgentDefsAtProxyStartup(config, port),
);
if (!startupSync.ran) console.log(" Codex integration OFF; startup left Codex native.");
// #1046: one warning per startup, after BOTH writes. The server's cache
// invalidation happens first and the catalog sync second, so the mtime is only
Expand Down Expand Up @@ -469,6 +477,7 @@ async function handleEnsure(options: { existingIsSuccess?: boolean } = {}): Prom
// Ensure env file exists for already-running proxy (may have been deleted or pre-dates this feature).
const systemEnv = await injectSystemEnv(live.port, config).catch(() => ({ injected: false }));
reportShellHookFailure(reconcileShellHook(systemEnv.injected));
if (!systemEnv.injected) await syncClaudeAgentDefsAtProxyStartup(config, live.port);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Refresh the Grok Build fence too (same contract as start). live.hostname is the
// hostname the running proxy actually bound — config.hostname may have drifted.
try {
Expand Down Expand Up @@ -512,6 +521,10 @@ async function handleEnsure(options: { existingIsSuccess?: boolean } = {}): Prom
return null;
});
if (synced?.status === "skipped") console.log(" Codex integration OFF; startup left Codex native.");
// The child opens /healthz before its best-effort roster reconcile. Await the same idempotent
// operation in the parent so `ocx ensure` cannot report success while stale ocx-*.md files are
// still observable. Always use the live port, including fallback-port starts.
await syncClaudeAgentDefsAtProxyStartup(config, port);
console.log(`✅ Proxy running on port ${port}`);
return true;
}
Expand Down
24 changes: 23 additions & 1 deletion structure/03_catalog-and-subagents.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,9 @@ instead. Codex's own `models_cache.json` is a different cache, invalidated by ca
## Startup readiness

Each `startServer` invocation owns a private, one-shot readiness gate created before the listener
binds. `handleStart` supplies its gate and transitions it after the shared catalog sync settles.
binds. `handleStart` supplies its gate and transitions it only after the shared catalog sync and
best-effort Claude Code roster reconciliation have both settled. The catalog sync remains the
authority for ready versus failed; a roster warning does not make an otherwise healthy proxy fail.
Calls without a supplied gate receive a fresh private gate that intentionally remains pending. Only
`ok: true` with no nonempty warning becomes ready; `null`, a throw, `ok !== true`, or a nonempty
warning becomes failed. State is isolated per server instance.
Expand Down Expand Up @@ -340,3 +342,23 @@ native passthrough is enabled; `modelMap` claims and `nativePassthrough:false` r
guard avoids creating oversized skill messages before the proxy can intervene; inbound elision remains
the fallback if a client still sends a blocked bundle. An explicit empty list disables both routed-model
behaviors.

[Decision Log]
- 목적과 의도: keep generated Claude Code `ocx-*.md` roster files synchronized when the proxy is
started or ensured on Linux, Windows, and macOS, including background service restarts.
- 기존 구현 및 제약 조건: explicit `ocx claude` launches and Management API writes reconciled the
files, while the startup call inside `injectSystemEnv` ran only on macOS with system-env enabled.
`startServer` is also used as an in-process library/test primitive and cannot safely mutate the
real user home on every invocation.
- 검토한 주요 대안: write from `startServer`; duplicate hooks in each OS service manager; reconcile
once from the owning CLI lifecycle after the listener becomes available.
- 선택한 방식: the foreground/service start and live-proxy ensure paths call one best-effort helper
after bind, using the live Management API context-window map and the existing marker-verified
atomic roster writer. macOS system-env startup keeps its existing shared-window sync and skips the
duplicate call.
- 다른 대안 대신 이 방식을 선택한 이유: it covers every supported service entrypoint without
adding home-directory side effects to server-library consumers or creating a second roster format.
- 장점, 단점 및 영향: stale OpenCodex-owned definitions converge on every daemon start, disabled integration
prunes them without provider discovery, and catalog failure falls back to unmarked definitions so
startup remains available. A later dashboard save or `ocx claude` launch restores missing context
markers after a transient failure.
116 changes: 116 additions & 0 deletions tests/claude-agent-startup-sync.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { describe, expect, test } from "bun:test";
import { mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
reconcileClientStartupBeforeReady,
syncClaudeAgentDefsAtProxyStartup,
} from "../src/cli/claude-agent-startup-sync";
import { injectClaudeAgentDefs } from "../src/claude/agents-inject";
import { createReadinessGate } from "../src/server/readiness";
import type { OcxConfig } from "../src/types";

const config = (claudeCode: OcxConfig["claudeCode"] = {}): OcxConfig => ({
providers: [],
claudeCode,
} as OcxConfig);

describe("Claude agent roster proxy-start synchronization (#2200)", () => {
test("keeps readiness pending until the best-effort roster fence settles", async () => {
const gate = createReadinessGate();
let releaseRoster!: () => void;
const rosterPending = new Promise<void>(resolve => { releaseRoster = resolve; });

const startup = reconcileClientStartupBeforeReady(
gate,
async deferredGate => {
deferredGate.markReady();
return { ran: true };
},
() => rosterPending,
);

await Promise.resolve();
expect(gate.getStatus()).toBe("pending");
releaseRoster();
expect(await startup).toEqual({ ran: true });
expect(gate.getStatus()).toBe("ready");
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

test("uses the live proxy context-window map for an enabled roster", async () => {
const calls: Array<{ port: number; windows?: Record<string, number> }> = [];
const result = await syncClaudeAgentDefsAtProxyStartup(config(), 10100, {
fetchContextWindows: async (_cfg, port) => {
calls.push({ port });
return { "google/gemini-3.7-flash": 1_000_000 };
},
injectAgentDefs: (_cfg, windows) => {
calls.push({ port: 0, windows });
return ["ocx-google-gemini-3-7-flash.md"];
},
});

expect(result).toEqual(["ocx-google-gemini-3-7-flash.md"]);
expect(calls).toEqual([
{ port: 10100 },
{ port: 0, windows: { "google/gemini-3.7-flash": 1_000_000 } },
]);
});

test("disabled integration prunes owned definitions without touching discovery", async () => {
let fetched = false;
let injected: Record<string, number> | undefined;
const result = await syncClaudeAgentDefsAtProxyStartup(config({ injectAgents: false }), 10100, {
fetchContextWindows: async () => {
fetched = true;
return { stale: 1_000_000 };
},
injectAgentDefs: (_cfg, windows) => {
injected = windows;
return [];
},
});

expect(result).toEqual([]);
expect(fetched).toBe(false);
expect(injected).toEqual({});
});

test("catalog failure still runs the real injector with an unmarked roster", async () => {
const dir = mkdtempSync(join(tmpdir(), "ocx-startup-roster-"));
try {
const configured = {
providers: [],
subagentModels: ["gpt-5.6-sol"],
claudeCode: { model: "gpt-5.6-sol" },
} as OcxConfig;
const result = await syncClaudeAgentDefsAtProxyStartup(configured, 10100, {
fetchContextWindows: async () => { throw new Error("catalog unavailable"); },
injectAgentDefs: (cfg, windows) => injectClaudeAgentDefs(cfg, windows, dir),
});

expect(result?.sort()).toEqual(["ocx-gpt-5-6-sol.md", "ocx-self.md"]);
expect(readdirSync(join(dir, "agents")).sort()).toEqual(result?.sort());
for (const file of result ?? []) {
const body = readFileSync(join(dir, "agents", file), "utf8");
expect(body).toContain("generated-by: opencodex");
expect(body).not.toContain("[1m]");
}
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

test("write failures are warned and never fail proxy startup", async () => {
const warnings: string[] = [];
const result = await syncClaudeAgentDefsAtProxyStartup(config(), 10100, {
fetchContextWindows: async () => ({}),
injectAgentDefs: () => { throw new Error("permission denied"); },
warn: message => warnings.push(message),
});

expect(result).toBeNull();
expect(warnings).toHaveLength(1);
expect(warnings[0]).toContain("permission denied");
});
});
Loading
Loading