Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
3a0b88f
fix(openai-responses): repair oversized replay call_ids on non-forwar…
jgautheron Jul 27, 2026
41deeef
fix(claude/inbound): prefer content-based prompt_cache_key over sessi…
jgautheron Jul 27, 2026
23ec63f
fix(cli): ocx restart no longer silently no-ops when codexAutoStart i…
jgautheron Jul 27, 2026
ad58ac8
Merge fix/call-id-oversized-replay into main
jgautheron Jul 27, 2026
0215404
Merge fix/cross-session-cache-key-priority into main
jgautheron Jul 27, 2026
44d0294
Merge fix/restart-ignores-codex-autostart into main
jgautheron Jul 27, 2026
152682b
fix: serialize parallel subagent dispatches sharing a cold prompt_cac…
jgautheron Jul 27, 2026
627e43c
Merge fix/cold-cache-key-parallel-lease into main
jgautheron Jul 27, 2026
5823712
feat(router): warn when a provider's bare gpt-/o1-/o3-/o4- model is u…
jgautheron Jul 27, 2026
4430ed3
Merge fix/warn-unreachable-bare-model-aliases into main
jgautheron Jul 27, 2026
d9a2a55
Merge remote-tracking branch 'upstream/dev'
jgautheron Jul 27, 2026
9ce0464
feat(router): extend bare-model warning to MODEL_PROVIDER_PATTERNS co…
jgautheron Jul 27, 2026
00b3861
Merge fix/warn-unreachable-bare-model-aliases (pattern-table extensio…
jgautheron Jul 27, 2026
ff0596d
fix(cli): restart/tray-start lost the Grok fence + Codex model sync
jgautheron Jul 27, 2026
e20c21e
test(claude-inbound): fix devlog 130 B3 test to match content-first c…
jgautheron Jul 27, 2026
8298112
Merge branch 'fix/cache-key-provenance-test-parity'
jgautheron Jul 27, 2026
b001af9
docs(router): simplify bare-model-alias warning comment
jgautheron Jul 27, 2026
7b0fb67
docs: simplify cold-cache-key-lease comments
jgautheron Jul 27, 2026
ba2085b
docs(claude/inbound): simplify cache-key-priority comments
jgautheron Jul 27, 2026
2f3a7dd
docs: simplify restart/grok-sync-parity comments
jgautheron Jul 27, 2026
b4f95ae
docs(claude-inbound): simplify cache-key test comments
jgautheron Jul 27, 2026
bae00dd
Merge branch 'fix/warn-unreachable-bare-model-aliases'
jgautheron Jul 27, 2026
4da468a
Merge branch 'fix/cold-cache-key-parallel-lease'
jgautheron Jul 27, 2026
479390e
Merge branch 'fix/cross-session-cache-key-priority'
jgautheron Jul 27, 2026
a0972c2
Merge branch 'fix/restart-grok-model-sync-parity'
jgautheron Jul 27, 2026
11cdee2
Merge branch 'fix/cache-key-provenance-test-parity'
jgautheron Jul 27, 2026
d637087
fix(usage): generic wildcard overlay fallback for private-gateway pri…
jgautheron Jul 27, 2026
3af6d4a
fix(adapters): extract cached_tokens from Responses API usage
jgautheron Jul 27, 2026
bfda29d
test(router): rename ambiguous fixture provider key
jgautheron Jul 27, 2026
d99a373
Merge branch 'fix/openai-responses-cached-tokens'
jgautheron Jul 27, 2026
b8ddda0
Merge branch 'fix/warn-test-fixture-provider-naming'
jgautheron Jul 27, 2026
8cc4747
fix(claude): stabilize routed cache inputs
jgautheron Jul 28, 2026
9e89c41
fix(claude): preserve native cache session affinity
jgautheron Jul 28, 2026
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 @@ -383,9 +383,10 @@ other 5xx `api_error`. `Retry-After` is preserved.
and the penultimate user message, plus top-level automatic `cache_control`. Stable turns normally
produce about a 99.9% cache hit rate.

**Native OpenAI/ChatGPT routing:** derives a session-scoped `prompt_cache_key` (from
`metadata.user_id` when present, falling back to a system-content hash) and `session_id` header
for cache affinity. The cache key includes model and full tool schemas.
**Native OpenAI/ChatGPT routing:** derives a content-scoped `prompt_cache_key` from the
resolved model, normalized system content, and full tool schemas. When `metadata.user_id` is
present, it separately derives a per-session `session_id` header for backend affinity. Tool and
system-reminder listings are canonicalized before either cache input is built.
Comment on lines +386 to +389

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
fd -i claude-code.md docs-site/src/content/docs | grep -E '/(ja|ko|ru|zh-cn)/'

Repository: lidge-jun/opencodex

Length of output: 368


🏁 Script executed:

#!/bin/bash
set -euo pipefail

for f in \
  docs-site/src/content/docs/ja/guides/claude-code.md \
  docs-site/src/content/docs/ko/guides/claude-code.md \
  docs-site/src/content/docs/ru/guides/claude-code.md \
  docs-site/src/content/docs/zh-cn/guides/claude-code.md \
  docs-site/src/content/docs/guides/claude-code.md
do
  echo "===== $f ====="
  # show the caching section area if present
  rg -n -C 3 'prompt_cache_key|session_id|session-scoped|content-scoped|cache' "$f" || true
  echo
done

Repository: lidge-jun/opencodex

Length of output: 8058


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
files = [
    Path('docs-site/src/content/docs/ja/guides/claude-code.md'),
    Path('docs-site/src/content/docs/ko/guides/claude-code.md'),
    Path('docs-site/src/content/docs/ru/guides/claude-code.md'),
    Path('docs-site/src/content/docs/zh-cn/guides/claude-code.md'),
]
for f in files:
    txt = f.read_text(encoding='utf-8')
    print(f"\n===== {f} =====")
    for needle in ['session-scoped', 'content-scoped', 'prompt_cache_key', 'session_id', 'normalized system content', 'tool schemas']:
        if needle in txt:
            print(f"contains: {needle}")
    # print the surrounding paragraph for prompt_cache_key if present
    idx = txt.find('prompt_cache_key')
    if idx != -1:
        start = max(0, txt.rfind('\n\n', 0, idx))
        end = txt.find('\n\n', idx)
        if end == -1:
            end = len(txt)
        print(txt[start:end].strip())
PY

Repository: lidge-jun/opencodex

Length of output: 1353


Update the translated caching section in the locale docs. docs-site/src/content/docs/ja/guides/claude-code.md:300-302, docs-site/src/content/docs/ko/guides/claude-code.md:337-339, docs-site/src/content/docs/ru/guides/claude-code.md:317-319, and docs-site/src/content/docs/zh-cn/guides/claude-code.md:295-297 still describe the old session-scoped prompt_cache_key / session_id model. Please align them with docs-site/src/content/docs/guides/claude-code.md:386-389, which now uses a content-scoped prompt_cache_key and only derives session_id separately for backend affinity when metadata.user_id is present.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs-site/src/content/docs/guides/claude-code.md` around lines 386 - 389,
Update the translated caching sections in the Japanese, Korean, Russian, and
Simplified Chinese Claude Code guides to match the current behavior described in
the native caching section: derive content-scoped prompt_cache_key values from
resolved model, normalized system content, and full tool schemas, and derive
session_id separately only when metadata.user_id is present for backend
affinity. Replace the outdated session-scoped prompt_cache_key/session_id
description while preserving the localized documentation structure.

Source: Path instructions


**Token math:** Anthropic output subtracts `cached_tokens` and `cache_write_tokens` from
`input_tokens`, exposing them as `cache_read_input_tokens` and `cache_creation_input_tokens`.
Expand Down
78 changes: 78 additions & 0 deletions src/adapters/anthropic-sort-stabilize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* Deterministic ordering of Claude Code's own tool/skills/deferred-tools listings on
* the native Anthropic passthrough (`anthropicNativePassthrough`, `claude-messages.ts`).
* Claude Code enumerates MCP tools/skills in whatever order its own reconnect/discovery
* race resolves them, so byte-identical conversations can arrive with different array
* order turn to turn — busting Anthropic's prompt-cache prefix for no reason. Sorting is
* safe: `tool_choice` targets tools by name, not position, and the two system-reminder
* blocks below are pure listings with no inherent order the model depends on.
*
* Ported (algorithm only, re-implemented in TypeScript) from `sort-stabilization.mjs`,
* MIT licensed, github.com/cnighswonger/claude-code-cache-fix.
*/

type Rec = Record<string, unknown>;

function isRec(v: unknown): v is Rec {
return !!v && typeof v === "object" && !Array.isArray(v);
}

const SKILLS_BLOCK_RE = /^([\s\S]*?\n\n)(- [\s\S]+?)(\n<\/system-reminder>\s*)$/;
const DEFERRED_TOOLS_BLOCK_RE = /^(<system-reminder>\nThe following deferred tools are now available[^\n]*\n)([\s\S]+?)(\n<\/system-reminder>\s*)$/;

export function isSkillsBlockText(text: unknown): text is string {
return typeof text === "string" && text.includes("User-invocable skills");
}

export function isDeferredToolsBlockText(text: unknown): text is string {
return typeof text === "string" && text.includes("deferred tools are now available");
}

export function sortSkillsBlockText(text: string): string {
const match = text.match(SKILLS_BLOCK_RE);
if (!match) return text;
const [, header, entriesText, footer] = match;
const entries = entriesText.split(/\n(?=- )/);
entries.sort();
return header + entries.join("\n") + footer;
}

export function sortDeferredToolsBlockText(text: string): string {
const match = text.match(DEFERRED_TOOLS_BLOCK_RE);
if (!match) return text;
const [, header, toolsList, footer] = match;
const tools = toolsList.split("\n").map(t => t.trim()).filter(Boolean);
tools.sort();
return header + tools.join("\n") + footer;
}

/** Normalize known system-reminder listings; leave all other instructions untouched. */
export function normalizeSystemReminderText(text: string): string {
if (isSkillsBlockText(text)) return sortSkillsBlockText(text);
if (isDeferredToolsBlockText(text)) return sortDeferredToolsBlockText(text);
return text;
}

/** Sort tool definitions by name; unnamed server tools sort first. */
export function sortToolsByName(tools: unknown[]): void {
tools.sort((a, b) => {
const nameA = isRec(a) && typeof a.name === "string" ? a.name : "";
const nameB = isRec(b) && typeof b.name === "string" ? b.name : "";
return nameA.localeCompare(nameB);
});
}

/** Sort `body.system` skills/deferred-tools listings and `body.tools` by name, in place. */
export function stabilizeSystemAndToolOrder(body: Rec): void {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Wire the native Anthropic stabilizer into passthrough

This helper is exported for native Anthropic passthrough, but it has no callers in this commit, so anthropicNativePassthrough still serializes the original body unchanged. In native Anthropic mode, Claude Code tool/system-reminder discovery can still arrive in different orders and continue busting the prompt-cache prefix; call this before JSON.stringify(body) on the passthrough path (including count_tokens if counts must match sends).

Useful? React with 👍 / 👎.

if (Array.isArray(body.system)) {
const system = body.system as unknown[];
for (let i = 0; i < system.length; i++) {
const block = system[i];
if (!isRec(block) || block.type !== "text" || typeof block.text !== "string") continue;
const normalized = normalizeSystemReminderText(block.text);
if (normalized !== block.text) system[i] = { ...block, text: normalized };
}
}

if (Array.isArray(body.tools)) sortToolsByName(body.tools as unknown[]);
}
Comment on lines +66 to +78

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n "stabilizeSystemAndToolOrder" -C3

Repository: lidge-jun/opencodex

Length of output: 157


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n## Files mentioning anthropic-sort-stabilize or related helpers\n'
rg -n "stabilizeSystemAndToolOrder|normalizeSystemReminderText|sortToolsByName|anthropicNativePassthrough|wantsNativePassthrough" src -C 2 || true

printf '\n## Candidate file outline\n'
ast-grep outline src/adapters/anthropic-sort-stabilize.ts || true

printf '\n## Relevant slices\n'
sed -n '1,220p' src/adapters/anthropic-sort-stabilize.ts

Repository: lidge-jun/opencodex

Length of output: 11231


Call stabilizeSystemAndToolOrder from the native passthrough path. src/adapters/anthropic-sort-stabilize.ts:66-78 exports the cache-stabilizing helper, but there is no call site anywhere in the repo, and src/server/claude-messages.ts:291, 573-574, 856-857 forwards the raw body into anthropicNativePassthrough(...). That means the system/tool ordering normalization never runs for native Anthropic requests, so byte-identical conversations can still miss the prompt cache. Invoke it before serialization in anthropicNativePassthrough, or drop the export/docstring if the passthrough is meant to stay unchanged.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/adapters/anthropic-sort-stabilize.ts` around lines 66 - 78, Invoke
stabilizeSystemAndToolOrder on the native Anthropic request body inside
anthropicNativePassthrough before the body is serialized or forwarded, ensuring
system reminders and tools are normalized for cache stability. Keep the existing
passthrough behavior otherwise unchanged.

15 changes: 14 additions & 1 deletion src/adapters/openai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -660,10 +660,14 @@ function usageFromResponsesPayload(payload: unknown): OcxUsage | undefined {
const inputTokens = typeof usage.input_tokens === "number" ? usage.input_tokens : 0;
const outputTokens = typeof usage.output_tokens === "number" ? usage.output_tokens : 0;
if (inputTokens === 0 && outputTokens === 0) return undefined;
const inputDetails = isPlainObject(usage.input_tokens_details) ? usage.input_tokens_details : undefined;
const outputDetails = isPlainObject(usage.output_tokens_details) ? usage.output_tokens_details : undefined;
return {
inputTokens,
outputTokens,
...(typeof usage.total_tokens === "number" ? { totalTokens: usage.total_tokens } : {}),
...(typeof inputDetails?.cached_tokens === "number" ? { cachedInputTokens: inputDetails.cached_tokens } : {}),
...(typeof outputDetails?.reasoning_tokens === "number" ? { reasoningOutputTokens: outputDetails.reasoning_tokens } : {}),
Comment on lines +663 to +670

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Add a parser-level regression test for the new token details.

usageFromResponsesPayload now maps input_tokens_details.cached_tokens and output_tokens_details.reasoning_tokens, but the supplied test changes do not exercise this adapter parser. A field-name or event-shape regression could silently drop cached and reasoning usage before aggregation. Add a focused test in tests/openai-responses-passthrough.test.ts asserting both fields on the emitted done.usage.

As per path instructions, adapter behavior changes under src/** should have a focused regression test under tests/.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/adapters/openai-responses.ts` around lines 663 - 670, Add a focused
regression test in openai-responses-passthrough.test.ts that exercises
usageFromResponsesPayload through the adapter and verifies the emitted
done.usage includes cachedInputTokens from input_tokens_details.cached_tokens
and reasoningOutputTokens from output_tokens_details.reasoning_tokens. Use a
payload containing both nested fields and assert both mapped values to guard
against parser field-name or event-shape regressions.

Source: Path instructions

};
}

Expand Down Expand Up @@ -741,7 +745,16 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
outBody = stripUnsupportedForwardParams(outBody);
}
else outBody = stripConflictingHostedTools(outBody);
if (forward || parsed._previousResponseInputExpanded === true) {
// Claude Code's Messages API never sets previousResponseId at all, so on a
// custom apiKey-auth provider (non-forward) the original forward-only guard
// never fired, and long tool-chains hit "Invalid 'input[N].call_id': string
// too long" (call_id replay can exceed the Responses API's 64-char limit).
// !unexpandedMiss is a safe superset of the old condition: it still covers
// forward mode and already-expanded replays, but also covers any request with
// no previousResponseId at all (every Claude-surfaced request) — while still
// excluding the one genuinely risky case, a raw unexpanded native-Codex
// continuation that might reference call ids stored upstream.
if (!unexpandedMiss) {
outBody = repairOversizedReplayCallIds(outBody);
}
outBody = stripUnsupportedReasoningSummaryDelivery(outBody, parsed.modelId);
Expand Down
73 changes: 48 additions & 25 deletions src/claude/inbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
* - top_k is accepted and silently dropped (no Responses equivalent, CCR parity).
*/
import type { OcxClaudeCodeConfig } from "../types";
import {
normalizeSystemReminderText,
sortToolsByName,
} from "../adapters/anthropic-sort-stabilize";
import { resolveAlias } from "./alias";
import { stripOneMillionMarker } from "./context-windows";
import { resolveDesktop3pAlias } from "./desktop-3p";
Expand Down Expand Up @@ -69,11 +73,12 @@ export function effortFromOutputConfig(outputConfig: unknown): string | undefine
}

function systemToInstructions(system: unknown): string | undefined {
if (typeof system === "string") return system.length > 0 ? system : undefined;
if (typeof system === "string") return system.length > 0 ? normalizeSystemReminderText(system) : undefined;
if (Array.isArray(system)) {
const parts: string[] = [];
for (const block of system) {
if (isRec(block) && block.type === "text" && typeof block.text === "string") parts.push(block.text);
if (!isRec(block) || block.type !== "text" || typeof block.text !== "string") continue;
parts.push(normalizeSystemReminderText(block.text));
}
return parts.length > 0 ? parts.join("\n\n") : undefined;
}
Expand Down Expand Up @@ -229,11 +234,13 @@ function blockedSkillCallIds(messages: readonly unknown[], blocked: readonly str
* `instructions` is the only shape that works on every route.
*/
function systemMessageText(content: unknown): string {
if (typeof content === "string") return content;
if (typeof content === "string") return normalizeSystemReminderText(content);
if (!Array.isArray(content)) return "";
const parts: string[] = [];
for (const raw of content) {
if (isRec(raw) && raw.type === "text" && typeof raw.text === "string") parts.push(raw.text);
if (isRec(raw) && raw.type === "text" && typeof raw.text === "string") {
parts.push(normalizeSystemReminderText(raw.text));
}
}
return parts.join("\n\n");
}
Expand Down Expand Up @@ -340,7 +347,14 @@ function toolsToResponses(tools: unknown): Rec[] | undefined {
}
// Other server tools (bash_*, text_editor_*, ...) have no routed equivalent: drop.
}
return out.length > 0 ? out : undefined;
if (out.length === 0) return undefined;
// Claude Code's MCP tool discovery races non-deterministically turn to turn, so this
// array can arrive in a different order for an otherwise-identical conversation. This
// feeds both the outgoing wire body and prompt_cache_key below, so an unstable order
// busts the cache prefix and the cache-key routing together. tool_choice targets by
// name (see toolChoiceToResponses), not position, so sorting is behavior-preserving.
sortToolsByName(out);
return out;
}

function toolChoiceToResponses(choice: unknown, body: Rec): void {
Expand Down Expand Up @@ -443,26 +457,30 @@ export function anthropicToResponsesTranslation(raw: unknown, cc?: OcxClaudeCode
let cacheKeySource: ClaudeCacheKeySource = null;
if (isRec(raw.metadata) && typeof raw.metadata.user_id === "string") {
body.user = raw.metadata.user_id;
// OpenAI-side prompt caching is routed by prompt_cache_key (Codex clients send
// their session id; without it consecutive /v1/messages turns reported
// cached_tokens: 0 on the ChatGPT backend — devlog 090). Claude Code's
// metadata.user_id embeds the session uuid, so hashing it yields a stable
// per-session key with a bounded length/charset.
body.prompt_cache_key = createHash("sha256").update(raw.metadata.user_id).digest("hex").slice(0, 32);
cacheKeySource = "metadata";
} else if (systemParts.length > 0) {
// Claude Desktop sends no metadata.user_id (H1, devlog 130): without any key the
// ChatGPT/OpenAI backends reported cached_tokens:0 on every turn. Fall back to a
// cache-cohort hash (devlog 260712 B4 + Pro review 012): fingerprint what the
// upstream actually receives — resolved model, post-translation system, and the
// FULL translated tool definitions in WIRE ORDER (sorting the hash while sending
// a different order would break the key↔prefix correspondence). canonical JSON
// (recursive key sort) + a version field so future normalization changes never
// mix cohorts. system-only keys herded different models/toolsets into one key
// and burned OpenAI's ~15 RPM per-key routing budget (audit R1#4/R2#5/R1#10).
// Exact-prefix matching still isolates content; the key only steers routing
// affinity. Callers must NOT synthesize a session_id header from this fallback
// (audit 133 R2#3).
}
// OpenAI-side prompt caching is routed by prompt_cache_key, not pure byte-prefix
// matching (Codex clients send their session id; without any key, consecutive
// /v1/messages turns reported cached_tokens: 0 on the ChatGPT backend).
//
// Content-first: fingerprint what the upstream actually receives — resolved model,
// post-translation system, and the FULL translated tool definitions in WIRE ORDER
// (sorting the hash while sending a different order would break the key<->prefix
// correspondence). Canonical JSON (recursive key sort) + a version field so future
// normalization changes never mix cohorts.
//
// Deliberately preferred over metadata.user_id (Claude Code's session uuid) even
// when present: a session-scoped key means every NEW Claude Code session gets a
// fresh key, so a cache warmed by yesterday's session is unreachable today even for
// byte-identical system+tools content — this is a guaranteed cold rewrite on the
// first call of every session, not just cross-session drift. Content-based keying
// lets any session hit a cache any other session already warmed. Session-only keys
// were originally chosen because pure-content keying herds many different
// sessions/models/toolsets onto one key, burning OpenAI's per-key routing budget at
// multi-tenant scale; that risk is far smaller for a single-operator deployment
// where the actual pain is inter-session cache misses. Exact-prefix matching still
// isolates content; the key only steers routing affinity. Callers must NOT
// synthesize a session_id header from this fallback.
if (systemParts.length > 0) {
body.prompt_cache_key = createHash("sha256")
.update(canonicalJson({
version: 2,
Expand All @@ -472,6 +490,11 @@ export function anthropicToResponsesTranslation(raw: unknown, cc?: OcxClaudeCode
}))
.digest("hex").slice(0, 32);
cacheKeySource = "system";
} else if (isRec(raw.metadata) && typeof raw.metadata.user_id === "string") {
// No system content to fingerprint: fall back to the session-scoped key so the
// request still gets SOME key rather than none.
body.prompt_cache_key = createHash("sha256").update(raw.metadata.user_id).digest("hex").slice(0, 32);
cacheKeySource = "metadata";
}

const thinking = raw.thinking;
Expand Down
23 changes: 21 additions & 2 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,7 @@ async function handleEnsure() {

/** Fixed tray action: start the proxy without depending on codexAutoStart. */
async function handleTrayProxyStart(): Promise<void> {
const config = loadConfig();
const ok = await runTrayProxyStart({
findLive: findLiveProxy,
diagnoseService: () => {
Expand All @@ -395,7 +396,6 @@ async function handleTrayProxyStart(): Promise<void> {
},
startService: () => serviceCommand("start"),
startDirect: () => {
const config = loadConfig();
const port = (config.port ?? 10100) > 0 ? (config.port ?? 10100) : 10100;
const child = spawn(process.execPath, startArgv(port), {
detached: true,
Expand All @@ -406,6 +406,19 @@ async function handleTrayProxyStart(): Promise<void> {
child.unref();
},
waitForProxy,
onStarted: async port => {
await syncModelsToCodex(port).catch(e => {
console.error(`⚠️ Model sync skipped: ${e instanceof Error ? e.message : String(e)}`);
});
try {
const { syncGrokConfig } = await import("../grok/sync");
const g = await syncGrokConfig(port, config, config.hostname ? { hostname: config.hostname } : {});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the live hostname when syncing Grok

When runTrayProxyStart finds an already-running proxy (or a newly started fallback runtime), findLiveProxy()/waitForProxy() can return the hostname that actually answered, but this callback only receives port and then falls back to config.hostname. If the running proxy was discovered from the runtime record or the config hostname has drifted, this rewrites ~/.grok/config.toml to the wrong host; pass the live hostname through the tray start callback and use it like handleEnsure does.

Useful? React with 👍 / 👎.

if (g.changed) console.log(" + Grok Build config updated (~/.grok/config.toml)");
else if (!g.ok) console.error(`⚠️ ${g.message}`);
} catch (err) {
console.error(`⚠️ ${grokSyncFailureMessage(err)}`);
}
},
info: message => console.log(message),
error: message => console.error(message),
});
Expand Down Expand Up @@ -887,7 +900,13 @@ switch (command) {
case "restart": {
// A failed stop must not be followed by a re-inject: with a foreign service still running
// (ownership mismatch) we would rewrite shared config we just declined to touch.
if (await handleStop()) await handleEnsure();
//
// handleEnsure() early-returns without relaunching when codexAutoStart is disabled —
// that flag governs whether opencodex registers itself with the Codex CLI on boot, an
// unrelated concern, but it silently ate `restart` too: stop succeeded, nothing ever
// came back up. handleTrayProxyStart() (already used by the tray's own restart action)
// relaunches the proxy unconditionally, independent of codexAutoStart.
if (await handleStop()) await handleTrayProxyStart();
else console.error("↩️ Restart aborted: the proxy was not stopped cleanly.");
break;
}
Expand Down
10 changes: 10 additions & 0 deletions src/cli/tray-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,22 @@ export interface TrayProxyStartIo {
waitForProxy: () => Promise<TrayProxyLive | null>;
info: (message: string) => void;
error: (message: string) => void;
/**
* Called once a live proxy is confirmed on `port` — whether it was already running or
* this call just started it. `handleEnsure` performs the Grok-fence and Codex-model
* sync inline in both of its branches; this action skipped both entirely, so `ocx restart`
* silently lost them once restart was repointed at this action. Optional so tests that
* don't care about the sync can omit it.
*/
onStarted?: (port: number) => void | Promise<void>;
Comment on lines +17 to +24

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Propagate the actual proxy hostname through startup callbacks.

The callback contract discards TrayProxyLive.hostname, causing Grok synchronization to use potentially stale config.hostname.

  • src/cli/tray-proxy.ts#L17-L24: extend onStarted to receive the hostname or full TrayProxyLive.
  • src/cli/tray-proxy.ts#L29-L32: pass the live proxy’s hostname on the already-running path.
  • src/cli/tray-proxy.ts#L46-L52: pass the started proxy’s hostname after a fresh start.
  • src/cli/index.ts#L409-L421: use the propagated hostname when calling syncGrokConfig.
📍 Affects 2 files
  • src/cli/tray-proxy.ts#L17-L24 (this comment)
  • src/cli/tray-proxy.ts#L29-L32
  • src/cli/tray-proxy.ts#L46-L52
  • src/cli/index.ts#L409-L421
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/cli/tray-proxy.ts` around lines 17 - 24, Update the onStarted callback
contract in src/cli/tray-proxy.ts:17-24 to carry the live proxy hostname or
TrayProxyLive object, pass that value from both the already-running path at
src/cli/tray-proxy.ts:29-32 and fresh-start path at src/cli/tray-proxy.ts:46-52,
then use the propagated hostname instead of config.hostname when invoking
syncGrokConfig in src/cli/index.ts:409-421.

}

/** Side-effect coordinator for the tray's fixed proxy-start action. */
export async function runTrayProxyStart(io: TrayProxyStartIo): Promise<boolean> {
const live = await io.findLive();
if (live) {
io.info(`Proxy already running on port ${live.port}.`);
await io.onStarted?.(live.port);
return true;
}

Expand All @@ -40,6 +49,7 @@ export async function runTrayProxyStart(io: TrayProxyStartIo): Promise<boolean>
return false;
}
io.info(`Proxy running on port ${started.port}.`);
await io.onStarted?.(started.port);
return true;
}

Expand Down
Loading
Loading