diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index 7c3a86dea3..869da6752a 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -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. @@ -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. diff --git a/docs-site/src/content/docs/ja/guides/claude-code.md b/docs-site/src/content/docs/ja/guides/claude-code.md index 532419e23f..bc7e84e39d 100644 --- a/docs-site/src/content/docs/ja/guides/claude-code.md +++ b/docs-site/src/content/docs/ja/guides/claude-code.md @@ -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` を使います。 diff --git a/docs-site/src/content/docs/ko/guides/claude-code.md b/docs-site/src/content/docs/ko/guides/claude-code.md index c5aae96d36..0183b5b72f 100644 --- a/docs-site/src/content/docs/ko/guides/claude-code.md +++ b/docs-site/src/content/docs/ko/guides/claude-code.md @@ -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`을 사용해요. diff --git a/docs-site/src/content/docs/ru/guides/claude-code.md b/docs-site/src/content/docs/ru/guides/claude-code.md index 46bc060c40..b04612625b 100644 --- a/docs-site/src/content/docs/ru/guides/claude-code.md +++ b/docs-site/src/content/docs/ru/guides/claude-code.md @@ -155,7 +155,7 @@ smallFastModel`; оно подставляется в обе переменны ## Агенты из ростера (injectAgents) -`ocx claude` (и демон системного окружения) синхронизирует ваш ростер избранных подагентов +Запуск/ensure прокси, `ocx claude` и сохранение связанных настроек в панели синхронизируют ваш ростер избранных подагентов (вкладка Subagents, до 5 моделей) плюс `ocx-self` в `~/.claude/agents/ocx-*.md`. - **`ocx-self`** закрепляет модель по умолчанию из селектора `/model` (с откатом на diff --git a/docs-site/src/content/docs/zh-cn/guides/claude-code.md b/docs-site/src/content/docs/zh-cn/guides/claude-code.md index 08fb965f27..e5140cb8a7 100644 --- a/docs-site/src/content/docs/zh-cn/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-cn/guides/claude-code.md @@ -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`);两者均 diff --git a/src/cli/claude-agent-startup-sync.ts b/src/cli/claude-agent-startup-sync.ts new file mode 100644 index 0000000000..10751ae8de --- /dev/null +++ b/src/cli/claude-agent-startup-sync.ts @@ -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( + readinessGate: ReadinessGate, + syncCodex: (deferredGate: ReadinessGate) => Promise, + syncClaudeRoster: () => Promise, +): Promise { + 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 { + 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 = {}; + 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; + } +} diff --git a/src/cli/index.ts b/src/cli/index.ts index e723db0e56..a2c1599b3d 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -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 @@ -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 @@ -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); // 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 { @@ -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; } diff --git a/structure/03_catalog-and-subagents.md b/structure/03_catalog-and-subagents.md index 49272b9f1c..52b635b7f7 100644 --- a/structure/03_catalog-and-subagents.md +++ b/structure/03_catalog-and-subagents.md @@ -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. @@ -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. diff --git a/tests/claude-agent-startup-sync.test.ts b/tests/claude-agent-startup-sync.test.ts new file mode 100644 index 0000000000..ab70857ac3 --- /dev/null +++ b/tests/claude-agent-startup-sync.test.ts @@ -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(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"); + }); + + test("uses the live proxy context-window map for an enabled roster", async () => { + const calls: Array<{ port: number; windows?: Record }> = []; + 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 | 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"); + }); +}); diff --git a/tests/cli-ready.test.ts b/tests/cli-ready.test.ts index cab4c1ab4c..577491e0ae 100644 --- a/tests/cli-ready.test.ts +++ b/tests/cli-ready.test.ts @@ -636,12 +636,10 @@ describe("runReady --wait deadline correctness", () => { // A bounded source-level assertion reading ONLY src/cli/index.ts. It verifies // that the SAME identifier `readinessGate` is (1) created in handleStart via // createReadinessGate(), (2) passed to startServer in the retry path, and -// (3) threaded into the startup sync via syncCodexOnStartIfEnabled(..., gate), -// in that order. The gate-drive itself lives in syncCodexOnStartIfEnabled (the -// modern dev startup path, which respects the Codex integration toggle and the -// #1046 write-tracking contract); this catches a regression where the gate is -// wired to only one of the two call sites. It complements the executable -// runStartupReadinessSync outcome tests in tests/proxy-liveness.test.ts. +// (3) passed to reconcileClientStartupBeforeReady before that helper gives a +// deferred gate to syncCodexOnStartIfEnabled. The successful transition is held +// until the Claude roster fence settles; this source guard complements the +// executable delayed-roster test in tests/claude-agent-startup-sync.test.ts. describe("handleStart readinessGate wiring (source-level)", () => { const cliSource = readFileSync(join(import.meta.dir, "../src/cli/index.ts"), "utf8"); @@ -652,17 +650,24 @@ describe("handleStart readinessGate wiring (source-level)", () => { const startMatch = cliSource.match(/startServer\s*\(\s*port\s*,\s*\{\s*[^}]*readinessGate[^}]*\}\s*\)/); expect(startMatch, "startServer must be called with readinessGate among its deps in the retry path").not.toBeNull(); + const reconcileMatch = cliSource.match( + /reconcileClientStartupBeforeReady\s*\(\s*readinessGate\s*,/, + ); + expect(reconcileMatch, "startup reconciliation must receive the server readinessGate").not.toBeNull(); + const syncMatch = cliSource.match( - /syncCodexOnStartIfEnabled\s*\(\s*port\s*,\s*config\s*,\s*undefined\s*,\s*readinessGate\s*\)/, + /gate\s*=>\s*syncCodexOnStartIfEnabled\s*\(\s*port\s*,\s*config\s*,\s*undefined\s*,\s*gate\s*\)/, ); - expect(syncMatch, "syncCodexOnStartIfEnabled must receive readinessGate so the startup sync drives /readyz").not.toBeNull(); + expect(syncMatch, "Codex startup sync must receive the deferred reconciliation gate").not.toBeNull(); - // Source order must be: create → startServer → startup sync. + // Source order must be: create → startServer → reconciliation → Codex sync. const createIdx = createMatch!.index!; const startIdx = startMatch!.index!; + const reconcileIdx = reconcileMatch!.index!; const syncIdx = syncMatch!.index!; expect(createIdx).toBeLessThan(startIdx); - expect(startIdx).toBeLessThan(syncIdx); + expect(startIdx).toBeLessThan(reconcileIdx); + expect(reconcileIdx).toBeLessThan(syncIdx); }); test("the readinessGate identifier is the SAME symbol at all three call sites", () => { @@ -670,7 +675,7 @@ describe("handleStart readinessGate wiring (source-level)", () => { // call site references that identifier (no shadowing, no second local). const declarations = cliSource.match(/\breadinessGate\s*=/g); expect(declarations, "readinessGate must be assigned exactly once").toHaveLength(1); - // Three references total: one declaration + startServer + startup sync. + // Three references total: one declaration + startServer + reconciliation helper. const references = cliSource.match(/\breadinessGate\b/g); expect(references?.length ?? 0).toBeGreaterThanOrEqual(3); });